mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(gateway): normalize Gemini Vertex embedding transport
This commit is contained in:
@@ -241,11 +241,29 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
|||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
request_requires_body_stream_field(body_json, force_body_stream_field),
|
request_requires_body_stream_field(body_json, force_body_stream_field),
|
||||||
);
|
);
|
||||||
apply_transport_request_body_semantics(
|
if let Err(err) = apply_transport_request_body_semantics(
|
||||||
&mut provider_request_body,
|
&mut provider_request_body,
|
||||||
transport,
|
transport,
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
);
|
) {
|
||||||
|
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
attempt.candidate_index,
|
||||||
|
&attempt.candidate_id,
|
||||||
|
"transport_request_body_semantics_failed",
|
||||||
|
CandidateFailureDiagnostic::request_conversion_failed(
|
||||||
|
spec_metadata.api_format,
|
||||||
|
provider_api_format,
|
||||||
|
"standard_family_transport_body_semantics",
|
||||||
|
err.to_string(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if let Some(mapping) =
|
if let Some(mapping) =
|
||||||
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
|
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
|
||||||
state,
|
state,
|
||||||
@@ -266,11 +284,29 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
|||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
request_requires_body_stream_field(body_json, force_body_stream_field),
|
request_requires_body_stream_field(body_json, force_body_stream_field),
|
||||||
);
|
);
|
||||||
apply_transport_request_body_semantics(
|
if let Err(err) = apply_transport_request_body_semantics(
|
||||||
&mut provider_request_body,
|
&mut provider_request_body,
|
||||||
transport,
|
transport,
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
);
|
) {
|
||||||
|
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
attempt.candidate_index,
|
||||||
|
&attempt.candidate_id,
|
||||||
|
"transport_request_body_semantics_failed",
|
||||||
|
CandidateFailureDiagnostic::request_conversion_failed(
|
||||||
|
spec_metadata.api_format,
|
||||||
|
provider_api_format,
|
||||||
|
"standard_family_transport_body_semantics_after_model_directives",
|
||||||
|
err.to_string(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||||
@@ -382,22 +418,12 @@ fn apply_transport_request_body_semantics(
|
|||||||
provider_request_body: &mut Value,
|
provider_request_body: &mut Value,
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
) {
|
) -> Result<(), crate::ai_serving::transport::TransportRequestBodySemanticsError> {
|
||||||
if !crate::ai_serving::api_format_alias_matches(provider_api_format, "gemini:embedding")
|
crate::ai_serving::transport::apply_transport_request_body_semantics(
|
||||||
|| !crate::ai_serving::transport::vertex::is_vertex_transport_context(transport)
|
provider_request_body,
|
||||||
{
|
transport,
|
||||||
return;
|
provider_api_format,
|
||||||
}
|
)
|
||||||
|
|
||||||
let Some(object) = provider_request_body.as_object_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if object.contains_key("requests") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
object.remove("model");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ pub(crate) use aether_provider_transport::{
|
|||||||
apply_local_body_rules_with_request_headers, apply_local_header_rules,
|
apply_local_body_rules_with_request_headers, apply_local_header_rules,
|
||||||
apply_local_header_rules_with_request_headers, apply_standard_provider_request_body_rules,
|
apply_local_header_rules_with_request_headers, apply_standard_provider_request_body_rules,
|
||||||
apply_standard_provider_request_body_rules_with_request_headers,
|
apply_standard_provider_request_body_rules_with_request_headers,
|
||||||
body_rules_are_locally_supported, body_rules_handle_path, body_rules_have_enabled_rules,
|
apply_transport_request_body_semantics, body_rules_are_locally_supported,
|
||||||
|
body_rules_handle_path, body_rules_have_enabled_rules,
|
||||||
build_cross_format_openai_chat_upstream_url, build_cross_format_openai_responses_upstream_url,
|
build_cross_format_openai_chat_upstream_url, build_cross_format_openai_responses_upstream_url,
|
||||||
build_gemini_files_headers, build_gemini_files_request_body, build_gemini_files_upstream_url,
|
build_gemini_files_headers, build_gemini_files_request_body, build_gemini_files_upstream_url,
|
||||||
build_kiro_cross_format_upstream_url, build_local_openai_chat_upstream_url,
|
build_kiro_cross_format_upstream_url, build_local_openai_chat_upstream_url,
|
||||||
@@ -89,5 +90,6 @@ pub(crate) use aether_provider_transport::{
|
|||||||
SameFormatProviderRequestBehaviorParams, SameFormatProviderRequestBodyInput,
|
SameFormatProviderRequestBehaviorParams, SameFormatProviderRequestBodyInput,
|
||||||
SameFormatProviderUpstreamUrlParams, StandardPlanFallbackAcceptPolicy,
|
SameFormatProviderUpstreamUrlParams, StandardPlanFallbackAcceptPolicy,
|
||||||
StandardPlanFallbackHeadersInput, StandardProviderRequestHeaders,
|
StandardPlanFallbackHeadersInput, StandardProviderRequestHeaders,
|
||||||
StandardProviderRequestHeadersInput, TransportRequestUrlParams,
|
StandardProviderRequestHeadersInput, TransportRequestBodySemanticsError,
|
||||||
|
TransportRequestUrlParams,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub(crate) fn normalized_signature(api_format: &str) -> Option<&'static str> {
|
|||||||
pub(crate) fn local_path(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() {
|
match crate::ai_serving::normalize_api_format_alias(api_format).as_str() {
|
||||||
"gemini" | "gemini:generate_content" => Some("/v1beta/models/{model}:{action}"),
|
"gemini" | "gemini:generate_content" => Some("/v1beta/models/{model}:{action}"),
|
||||||
"gemini:embedding" => Some("/v1/embeddings"),
|
"gemini:embedding" => Some("/v1beta/models/{model}:{action}"),
|
||||||
"gemini:video" => Some("/v1beta/models/{model}:predictLongRunning"),
|
"gemini:video" => Some("/v1beta/models/{model}:predictLongRunning"),
|
||||||
"gemini:files" => Some("/v1beta/files"),
|
"gemini:files" => Some("/v1beta/files"),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ mod tests {
|
|||||||
fn supports_data_api_endpoint_signatures_and_public_paths() {
|
fn supports_data_api_endpoint_signatures_and_public_paths() {
|
||||||
for (api_format, family, kind, path) in [
|
for (api_format, family, kind, path) in [
|
||||||
("openai:embedding", "openai", "embedding", "/v1/embeddings"),
|
("openai:embedding", "openai", "embedding", "/v1/embeddings"),
|
||||||
("gemini:embedding", "gemini", "embedding", "/v1/embeddings"),
|
(
|
||||||
|
"gemini:embedding",
|
||||||
|
"gemini",
|
||||||
|
"embedding",
|
||||||
|
"/v1beta/models/{model}:{action}",
|
||||||
|
),
|
||||||
("jina:embedding", "jina", "embedding", "/v1/embeddings"),
|
("jina:embedding", "jina", "embedding", "/v1/embeddings"),
|
||||||
("doubao:embedding", "doubao", "embedding", "/v1/embeddings"),
|
("doubao:embedding", "doubao", "embedding", "/v1/embeddings"),
|
||||||
("openai:rerank", "openai", "rerank", "/v1/rerank"),
|
("openai:rerank", "openai", "rerank", "/v1/rerank"),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use aether_contracts::{ExecutionPlan, ExecutionResult};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::orchestration::{
|
use crate::orchestration::{
|
||||||
resolve_local_failover_analysis_for_attempt, LocalFailoverAnalysis, LocalFailoverDecision,
|
resolve_local_failover_analysis_for_attempt, LocalFailoverAnalysis,
|
||||||
|
LocalFailoverClassification, LocalFailoverDecision,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -48,6 +49,15 @@ pub(crate) async fn analyze_local_candidate_failover_sync(
|
|||||||
return LocalFailoverAnalysis::use_default();
|
return LocalFailoverAnalysis::use_default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(error) = result.error.as_ref() {
|
||||||
|
if !error.retryable && !error.failover_recommended {
|
||||||
|
return LocalFailoverAnalysis {
|
||||||
|
classification: LocalFailoverClassification::StopExecutionError,
|
||||||
|
decision: LocalFailoverDecision::StopLocalFailover,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolve_local_failover_analysis_for_attempt(
|
resolve_local_failover_analysis_for_attempt(
|
||||||
state,
|
state,
|
||||||
plan,
|
plan,
|
||||||
@@ -326,14 +336,14 @@ pub(crate) fn resolve_core_stream_direct_finalize_report_kind(plan_kind: &str) -
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use aether_contracts::ExecutionResult;
|
use aether_contracts::{ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionResult};
|
||||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
resolve_core_stream_error_finalize_report_kind,
|
analyze_local_candidate_failover_sync, resolve_core_stream_error_finalize_report_kind,
|
||||||
resolve_core_sync_error_finalize_report_kind, should_fallback_to_control_stream,
|
resolve_core_sync_error_finalize_report_kind, should_fallback_to_control_stream,
|
||||||
should_fallback_to_control_sync, should_retry_next_local_candidate_stream,
|
should_fallback_to_control_sync, should_retry_next_local_candidate_stream,
|
||||||
should_retry_next_local_candidate_sync, should_stop_local_candidate_failover_stream,
|
should_retry_next_local_candidate_sync, should_stop_local_candidate_failover_stream,
|
||||||
@@ -607,6 +617,69 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_failover_honors_non_retryable_execution_error() {
|
||||||
|
let result = ExecutionResult {
|
||||||
|
request_id: "req-1".to_string(),
|
||||||
|
candidate_id: None,
|
||||||
|
status_code: 502,
|
||||||
|
headers: Default::default(),
|
||||||
|
body: None,
|
||||||
|
telemetry: None,
|
||||||
|
error: Some(ExecutionError {
|
||||||
|
kind: ExecutionErrorKind::Upstream5xx,
|
||||||
|
phase: ExecutionPhase::Finalize,
|
||||||
|
message: "provider returned HTTP 200 without visible model output".to_string(),
|
||||||
|
upstream_status: Some(200),
|
||||||
|
retryable: false,
|
||||||
|
failover_recommended: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let local_report_context = serde_json::json!({
|
||||||
|
"candidate_index": 0,
|
||||||
|
"retry_index": 0,
|
||||||
|
});
|
||||||
|
let state = build_state_with_provider_config(None);
|
||||||
|
let plan = sample_plan();
|
||||||
|
|
||||||
|
let analysis = analyze_local_candidate_failover_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
Some("provider returned HTTP 200 without visible model output"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
analysis.decision,
|
||||||
|
crate::orchestration::LocalFailoverDecision::StopLocalFailover
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!should_retry_next_local_candidate_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
Some("provider returned HTTP 200 without visible model output"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
should_stop_local_candidate_failover_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
Some("provider returned HTTP 200 without visible model output"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sync_retry_next_candidate_skips_video_follow_up_plan_kinds() {
|
async fn sync_retry_next_candidate_skips_video_follow_up_plan_kinds() {
|
||||||
let result = ExecutionResult {
|
let result = ExecutionResult {
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
}
|
}
|
||||||
"openai:embedding" | "gemini:embedding" | "jina:embedding" | "doubao:embedding"
|
"openai:embedding" | "gemini:embedding" | "jina:embedding" | "doubao:embedding"
|
||||||
| "openai:rerank" | "jina:rerank" => {
|
| "openai:rerank" | "jina:rerank" => {
|
||||||
let Some(provider_request_body) =
|
let Some(mut provider_request_body) =
|
||||||
crate::ai_serving::build_standard_request_body_with_model_directives_and_request_headers(
|
crate::ai_serving::build_standard_request_body_with_model_directives_and_request_headers(
|
||||||
&request_body,
|
&request_body,
|
||||||
client_api_format,
|
client_api_format,
|
||||||
@@ -2324,6 +2324,18 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
format!("Provider request body could not be built for {provider_api_format}"),
|
format!("Provider request body could not be built for {provider_api_format}"),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
if let Err(err) = crate::provider_transport::apply_transport_request_body_semantics(
|
||||||
|
&mut provider_request_body,
|
||||||
|
&transport,
|
||||||
|
normalized_provider_api_format.as_str(),
|
||||||
|
) {
|
||||||
|
return Ok(provider_query_skipped_execution_outcome(
|
||||||
|
provider_request_body,
|
||||||
|
format!(
|
||||||
|
"Provider request body is not compatible with transport semantics: {err}"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
provider_request_body
|
provider_request_body
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -2404,7 +2416,7 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
*synthetic_request.headers_mut() = incoming_request_headers;
|
*synthetic_request.headers_mut() = incoming_request_headers;
|
||||||
let (parts, _) = synthetic_request.into_parts();
|
let (parts, _) = synthetic_request.into_parts();
|
||||||
|
|
||||||
let request_url = crate::provider_transport::build_transport_request_url(
|
let request_url = crate::provider_transport::build_transport_request_url_for_request_body(
|
||||||
&transport,
|
&transport,
|
||||||
crate::provider_transport::TransportRequestUrlParams {
|
crate::provider_transport::TransportRequestUrlParams {
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
@@ -2413,6 +2425,7 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
request_query: parts.uri.query(),
|
request_query: parts.uri.query(),
|
||||||
kiro_api_region: None,
|
kiro_api_region: None,
|
||||||
},
|
},
|
||||||
|
Some(&provider_request_body),
|
||||||
);
|
);
|
||||||
let Some(request_url) = request_url else {
|
let Some(request_url) = request_url else {
|
||||||
return Ok(provider_query_skipped_execution_outcome(
|
return Ok(provider_query_skipped_execution_outcome(
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ pub(super) fn provider_query_standard_test_unsupported_reason(
|
|||||||
api_format,
|
api_format,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"gemini:generate_content"
|
"gemini:generate_content" | "gemini:embedding"
|
||||||
if crate::provider_transport::is_vertex_api_key_transport_context(transport) =>
|
if crate::provider_transport::is_vertex_transport_context(transport) =>
|
||||||
{
|
{
|
||||||
aether_provider_transport::vertex::local_vertex_api_key_gemini_transport_unsupported_reason_with_network(
|
aether_provider_transport::vertex::local_vertex_gemini_transport_unsupported_reason_with_network(
|
||||||
transport,
|
transport,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,29 @@ pub(super) fn provider_query_test_attempt_payload(
|
|||||||
candidate: &ProviderQueryTestCandidate,
|
candidate: &ProviderQueryTestCandidate,
|
||||||
execution: &ProviderQueryExecutionOutcome,
|
execution: &ProviderQueryExecutionOutcome,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
|
let endpoint_route = provider_query_endpoint_route_payload(candidate, execution);
|
||||||
|
let endpoint_product = endpoint_route
|
||||||
|
.get("product")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
let endpoint_variant = endpoint_route
|
||||||
|
.get("variant")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
let endpoint_action = endpoint_route.get("action").cloned().unwrap_or(Value::Null);
|
||||||
|
let endpoint_batch_strategy = endpoint_route
|
||||||
|
.get("batch_strategy")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
json!({
|
json!({
|
||||||
"candidate_index": candidate_index,
|
"candidate_index": candidate_index,
|
||||||
"retry_index": 0,
|
"retry_index": 0,
|
||||||
"endpoint_api_format": candidate.endpoint.api_format,
|
"endpoint_api_format": candidate.endpoint.api_format,
|
||||||
"endpoint_base_url": candidate.endpoint.base_url,
|
"endpoint_base_url": candidate.endpoint.base_url,
|
||||||
|
"endpoint_product": endpoint_product,
|
||||||
|
"endpoint_variant": endpoint_variant,
|
||||||
|
"endpoint_action": endpoint_action,
|
||||||
|
"endpoint_batch_strategy": endpoint_batch_strategy,
|
||||||
"key_name": provider_query_key_display_name(&candidate.key),
|
"key_name": provider_query_key_display_name(&candidate.key),
|
||||||
"key_id": candidate.key.id,
|
"key_id": candidate.key.id,
|
||||||
"auth_type": candidate.key.auth_type,
|
"auth_type": candidate.key.auth_type,
|
||||||
@@ -29,6 +47,129 @@ pub(super) fn provider_query_test_attempt_payload(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn provider_query_endpoint_route_payload(
|
||||||
|
candidate: &ProviderQueryTestCandidate,
|
||||||
|
execution: &ProviderQueryExecutionOutcome,
|
||||||
|
) -> Value {
|
||||||
|
let api_format = aether_ai_formats::normalize_api_format_alias(&candidate.endpoint.api_format);
|
||||||
|
let request_url = execution.request_url.to_ascii_lowercase();
|
||||||
|
let base_url = candidate.endpoint.base_url.to_ascii_lowercase();
|
||||||
|
let is_vertex = request_url.contains("aiplatform.googleapis.com")
|
||||||
|
|| base_url.contains("aiplatform.googleapis.com");
|
||||||
|
let is_gemini_api = request_url.contains("generativelanguage.googleapis.com")
|
||||||
|
|| base_url.contains("generativelanguage.googleapis.com");
|
||||||
|
let is_openai_compat =
|
||||||
|
request_url.contains("/endpoints/openapi") || request_url.contains("/openai/");
|
||||||
|
let is_batch = execution
|
||||||
|
.request_body
|
||||||
|
.get("requests")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| !items.is_empty());
|
||||||
|
let vertex_instance_count = execution
|
||||||
|
.request_body
|
||||||
|
.get("instances")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(Vec::len)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let (product, variant, action, batch_strategy) = match api_format.as_str() {
|
||||||
|
"gemini:embedding" if is_vertex => (
|
||||||
|
"Vertex AI",
|
||||||
|
"vertex_native",
|
||||||
|
"predict",
|
||||||
|
if vertex_instance_count > 1 {
|
||||||
|
"predict_instances"
|
||||||
|
} else {
|
||||||
|
"single_instance"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"gemini:embedding" if is_gemini_api => (
|
||||||
|
"Gemini API",
|
||||||
|
"gemini_native",
|
||||||
|
if is_batch {
|
||||||
|
"batchEmbedContents"
|
||||||
|
} else {
|
||||||
|
"embedContent"
|
||||||
|
},
|
||||||
|
if is_batch {
|
||||||
|
"native_batch"
|
||||||
|
} else {
|
||||||
|
"single_native"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"gemini:embedding" => (
|
||||||
|
"Gemini native",
|
||||||
|
"gemini_native",
|
||||||
|
if is_batch {
|
||||||
|
"batchEmbedContents"
|
||||||
|
} else {
|
||||||
|
"embedContent"
|
||||||
|
},
|
||||||
|
if is_batch {
|
||||||
|
"native_batch"
|
||||||
|
} else {
|
||||||
|
"single_native"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"gemini:generate_content" if is_vertex => {
|
||||||
|
("Vertex AI", "vertex_native", "generateContent", "")
|
||||||
|
}
|
||||||
|
"gemini:generate_content" if is_gemini_api => {
|
||||||
|
("Gemini API", "gemini_native", "generateContent", "")
|
||||||
|
}
|
||||||
|
"gemini:generate_content" => ("Gemini native", "gemini_native", "generateContent", ""),
|
||||||
|
"openai:embedding" if is_vertex && is_openai_compat => (
|
||||||
|
"Vertex AI OpenAI-compatible",
|
||||||
|
"openai_compatible",
|
||||||
|
"embeddings",
|
||||||
|
"openai_batch",
|
||||||
|
),
|
||||||
|
"openai:embedding" if is_gemini_api && is_openai_compat => (
|
||||||
|
"Gemini API OpenAI-compatible",
|
||||||
|
"openai_compatible",
|
||||||
|
"embeddings",
|
||||||
|
"openai_batch",
|
||||||
|
),
|
||||||
|
"openai:embedding" => (
|
||||||
|
"OpenAI-compatible",
|
||||||
|
"openai_compatible",
|
||||||
|
"embeddings",
|
||||||
|
"openai_batch",
|
||||||
|
),
|
||||||
|
"openai:chat" if is_vertex && is_openai_compat => (
|
||||||
|
"Vertex AI OpenAI-compatible",
|
||||||
|
"openai_compatible",
|
||||||
|
"chat/completions",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
"openai:chat" if is_gemini_api && is_openai_compat => (
|
||||||
|
"Gemini API OpenAI-compatible",
|
||||||
|
"openai_compatible",
|
||||||
|
"chat/completions",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
"openai:chat" => (
|
||||||
|
"OpenAI-compatible",
|
||||||
|
"openai_compatible",
|
||||||
|
"chat/completions",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
"Provider endpoint",
|
||||||
|
"provider_native",
|
||||||
|
"provider_request",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"product": product,
|
||||||
|
"variant": variant,
|
||||||
|
"action": action,
|
||||||
|
"batch_strategy": batch_strategy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn provider_query_candidate_summary_payload(
|
pub(super) fn provider_query_candidate_summary_payload(
|
||||||
total_candidates: usize,
|
total_candidates: usize,
|
||||||
total_attempts: usize,
|
total_attempts: usize,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub(crate) enum LocalFailoverClassification {
|
|||||||
UseDefault,
|
UseDefault,
|
||||||
StopStatusCode,
|
StopStatusCode,
|
||||||
StopErrorPattern,
|
StopErrorPattern,
|
||||||
|
StopExecutionError,
|
||||||
RetrySuccessPattern,
|
RetrySuccessPattern,
|
||||||
RetryStatusCode,
|
RetryStatusCode,
|
||||||
RetryUpstreamFailure,
|
RetryUpstreamFailure,
|
||||||
@@ -43,6 +44,7 @@ impl LocalFailoverClassification {
|
|||||||
Self::UseDefault => "use_default",
|
Self::UseDefault => "use_default",
|
||||||
Self::StopStatusCode => "stop_status_code",
|
Self::StopStatusCode => "stop_status_code",
|
||||||
Self::StopErrorPattern => "stop_error_pattern",
|
Self::StopErrorPattern => "stop_error_pattern",
|
||||||
|
Self::StopExecutionError => "stop_execution_error",
|
||||||
Self::RetrySuccessPattern => "retry_success_pattern",
|
Self::RetrySuccessPattern => "retry_success_pattern",
|
||||||
Self::RetryStatusCode => "retry_status_code",
|
Self::RetryStatusCode => "retry_status_code",
|
||||||
Self::RetryUpstreamFailure => "retry_upstream_failure",
|
Self::RetryUpstreamFailure => "retry_upstream_failure",
|
||||||
|
|||||||
@@ -821,7 +821,8 @@ fn local_candidate_failure_should_invalidate_affinity(
|
|||||||
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
|
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
|
||||||
status_code >= 500
|
status_code >= 500
|
||||||
}
|
}
|
||||||
LocalFailoverClassification::StopErrorPattern => false,
|
LocalFailoverClassification::StopErrorPattern
|
||||||
|
| LocalFailoverClassification::StopExecutionError => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ fn local_candidate_failure_should_project_health(
|
|||||||
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
|
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
|
||||||
status_code >= 500
|
status_code >= 500
|
||||||
}
|
}
|
||||||
LocalFailoverClassification::StopErrorPattern => false,
|
LocalFailoverClassification::StopErrorPattern
|
||||||
|
| LocalFailoverClassification::StopExecutionError => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ pub(crate) fn build_local_error_flow_metadata(
|
|||||||
) -> Value {
|
) -> Value {
|
||||||
let safe_to_expose = matches!(
|
let safe_to_expose = matches!(
|
||||||
analysis.classification,
|
analysis.classification,
|
||||||
LocalFailoverClassification::StopStatusCode | LocalFailoverClassification::StopErrorPattern
|
LocalFailoverClassification::StopStatusCode
|
||||||
|
| LocalFailoverClassification::StopErrorPattern
|
||||||
|
| LocalFailoverClassification::StopExecutionError
|
||||||
);
|
);
|
||||||
let propagation = match analysis.decision {
|
let propagation = match analysis.decision {
|
||||||
LocalFailoverDecision::RetryNextCandidate => "suppressed",
|
LocalFailoverDecision::RetryNextCandidate => "suppressed",
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ const fn decision_from_classification(
|
|||||||
match classification {
|
match classification {
|
||||||
LocalFailoverClassification::UseDefault => LocalFailoverDecision::UseDefault,
|
LocalFailoverClassification::UseDefault => LocalFailoverDecision::UseDefault,
|
||||||
LocalFailoverClassification::StopStatusCode
|
LocalFailoverClassification::StopStatusCode
|
||||||
| LocalFailoverClassification::StopErrorPattern => LocalFailoverDecision::StopLocalFailover,
|
| LocalFailoverClassification::StopErrorPattern
|
||||||
|
| LocalFailoverClassification::StopExecutionError => {
|
||||||
|
LocalFailoverDecision::StopLocalFailover
|
||||||
|
}
|
||||||
LocalFailoverClassification::RetrySuccessPattern
|
LocalFailoverClassification::RetrySuccessPattern
|
||||||
| LocalFailoverClassification::RetryStatusCode
|
| LocalFailoverClassification::RetryStatusCode
|
||||||
| LocalFailoverClassification::RetryUpstreamFailure => {
|
| LocalFailoverClassification::RetryUpstreamFailure => {
|
||||||
|
|||||||
@@ -1256,6 +1256,146 @@ async fn gateway_handles_admin_provider_query_gemini_embedding_model_test() {
|
|||||||
execution_runtime_handle.abort();
|
execution_runtime_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_handles_admin_provider_query_vertex_gemini_embedding_model_test() {
|
||||||
|
let execution_runtime = Router::new().route(
|
||||||
|
"/v1/execute/sync",
|
||||||
|
any(move |Json(plan): Json<ExecutionPlan>| async move {
|
||||||
|
assert_eq!(plan.provider_id, "provider-vertex-ai");
|
||||||
|
assert_eq!(plan.endpoint_id, "endpoint-vertex-gemini-embedding");
|
||||||
|
assert_eq!(plan.key_id, "key-vertex-gemini-embedding");
|
||||||
|
assert_eq!(plan.client_api_format, "openai:embedding");
|
||||||
|
assert_eq!(plan.provider_api_format, "gemini:embedding");
|
||||||
|
assert_eq!(
|
||||||
|
plan.url,
|
||||||
|
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-embedding-2:predict?key=sk-vertex-gemini-embedding"
|
||||||
|
);
|
||||||
|
assert_eq!(plan.model_name.as_deref(), Some("gemini-embedding-2"));
|
||||||
|
assert!(!plan.stream);
|
||||||
|
let body = plan.body.json_body.as_ref().expect("json body");
|
||||||
|
assert!(
|
||||||
|
body.get("model").is_none(),
|
||||||
|
"Vertex predict carries the model in the URL path; the test body must not repeat it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["instances"][0]["content"],
|
||||||
|
json!("This is a test embedding input.")
|
||||||
|
);
|
||||||
|
assert!(body.get("content").is_none());
|
||||||
|
assert!(body.get("requests").is_none());
|
||||||
|
assert!(
|
||||||
|
body.get("stream").is_none(),
|
||||||
|
"gemini embedding provider body must not carry stream"
|
||||||
|
);
|
||||||
|
Json(json!({
|
||||||
|
"request_id": plan.request_id,
|
||||||
|
"candidate_id": plan.candidate_id,
|
||||||
|
"status_code": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"json_body": {
|
||||||
|
"predictions": [
|
||||||
|
{
|
||||||
|
"embeddings": {
|
||||||
|
"values": [0.1, 0.2, 0.3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployedModelId": "gemini-embedding-2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telemetry": {
|
||||||
|
"elapsed_ms": 27
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||||
|
let mut provider = sample_provider("provider-vertex-ai", "Vertex AI", 10);
|
||||||
|
provider.provider_type = "vertex_ai".to_string();
|
||||||
|
let mut key = sample_key(
|
||||||
|
"key-vertex-gemini-embedding",
|
||||||
|
"provider-vertex-ai",
|
||||||
|
"gemini:embedding",
|
||||||
|
"sk-vertex-gemini-embedding",
|
||||||
|
);
|
||||||
|
key.allowed_models = Some(json!(["gemini-embedding-2"]));
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
vec![sample_endpoint(
|
||||||
|
"endpoint-vertex-gemini-embedding",
|
||||||
|
"provider-vertex-ai",
|
||||||
|
"gemini:embedding",
|
||||||
|
"https://aiplatform.googleapis.com",
|
||||||
|
)],
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY.to_string(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/provider-query/test-model"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&json!({
|
||||||
|
"provider_id": "provider-vertex-ai",
|
||||||
|
"model": "gemini-embedding-2",
|
||||||
|
"api_format": "gemini:embedding",
|
||||||
|
"endpoint_id": "endpoint-vertex-gemini-embedding",
|
||||||
|
"request_body": {
|
||||||
|
"model": "gemini-embedding-2",
|
||||||
|
"input": "This is a test embedding input."
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["success"], json!(true));
|
||||||
|
assert_eq!(payload["error"], serde_json::Value::Null);
|
||||||
|
assert_eq!(payload["attempts"][0]["status"], json!("success"));
|
||||||
|
assert_eq!(
|
||||||
|
payload["attempts"][0]["request_body"]["instances"][0]["content"],
|
||||||
|
json!("This is a test embedding input.")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["attempts"][0]["endpoint_product"],
|
||||||
|
json!("Vertex AI")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["attempts"][0]["endpoint_variant"],
|
||||||
|
json!("vertex_native")
|
||||||
|
);
|
||||||
|
assert_eq!(payload["attempts"][0]["endpoint_action"], json!("predict"));
|
||||||
|
assert_eq!(
|
||||||
|
payload["attempts"][0]["endpoint_batch_strategy"],
|
||||||
|
json!("single_instance")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload["attempts"][0]["request_body"]
|
||||||
|
.get("model")
|
||||||
|
.is_none(),
|
||||||
|
"attempt debug payload must expose the exact Vertex body without a duplicate model"
|
||||||
|
);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
execution_runtime_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_provider_query_jina_embedding_model_test() {
|
async fn gateway_handles_admin_provider_query_jina_embedding_model_test() {
|
||||||
let execution_runtime = Router::new().route(
|
let execution_runtime = Router::new().route(
|
||||||
|
|||||||
@@ -1152,6 +1152,14 @@ async fn gateway_handles_admin_system_api_formats_locally_with_trusted_admin_pri
|
|||||||
.expect("formats should be an array");
|
.expect("formats should be an array");
|
||||||
assert_eq!(formats[0]["value"], "openai:chat");
|
assert_eq!(formats[0]["value"], "openai:chat");
|
||||||
assert_eq!(formats[0]["default_path"], "/v1/chat/completions");
|
assert_eq!(formats[0]["default_path"], "/v1/chat/completions");
|
||||||
|
let gemini_embedding = formats
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["value"] == "gemini:embedding")
|
||||||
|
.expect("gemini embedding format should exist");
|
||||||
|
assert_eq!(
|
||||||
|
gemini_embedding["default_path"],
|
||||||
|
"/v1beta/models/{model}:{action}"
|
||||||
|
);
|
||||||
assert!(formats
|
assert!(formats
|
||||||
.iter()
|
.iter()
|
||||||
.any(|item| item["value"] == "openai:embedding"));
|
.any(|item| item["value"] == "openai:embedding"));
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ fn vertex_gemini_embedding_conversion_execution_runtime() -> Router {
|
|||||||
"/v1/execute/sync",
|
"/v1/execute/sync",
|
||||||
any(|Json(plan): Json<ExecutionPlan>| async move {
|
any(|Json(plan): Json<ExecutionPlan>| async move {
|
||||||
assert_openai_to_vertex_gemini_embedding_execution_plan(&plan);
|
assert_openai_to_vertex_gemini_embedding_execution_plan(&plan);
|
||||||
Json(gemini_embedding_execution_result(&plan))
|
Json(vertex_gemini_embedding_execution_result(&plan))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -342,7 +342,7 @@ fn assert_openai_to_vertex_gemini_embedding_execution_plan(plan: &ExecutionPlan)
|
|||||||
assert_eq!(plan.method, "POST");
|
assert_eq!(plan.method, "POST");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.url,
|
plan.url,
|
||||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-embedding-2:embedContent?key=sk-upstream-vertex-gemini-embedding"
|
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-embedding-2:predict?key=sk-upstream-vertex-gemini-embedding"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.model_name.as_deref(),
|
plan.model_name.as_deref(),
|
||||||
@@ -352,9 +352,10 @@ fn assert_openai_to_vertex_gemini_embedding_execution_plan(plan: &ExecutionPlan)
|
|||||||
let body = plan.body.json_body.as_ref().expect("json request body");
|
let body = plan.body.json_body.as_ref().expect("json request body");
|
||||||
assert!(
|
assert!(
|
||||||
body.get("model").is_none(),
|
body.get("model").is_none(),
|
||||||
"Vertex embedContent carries the model in the path; the body must not repeat it"
|
"Vertex predict carries the model in the path; the body must not repeat it"
|
||||||
);
|
);
|
||||||
assert_eq!(body["content"]["parts"][0]["text"], "hello");
|
assert_eq!(body["instances"][0]["content"], "hello");
|
||||||
|
assert!(body.get("content").is_none());
|
||||||
assert!(body.get("input").is_none());
|
assert!(body.get("input").is_none());
|
||||||
assert!(body.get("messages").is_none());
|
assert!(body.get("messages").is_none());
|
||||||
}
|
}
|
||||||
@@ -453,6 +454,30 @@ fn gemini_embedding_execution_result(plan: &ExecutionPlan) -> ExecutionResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vertex_gemini_embedding_execution_result(plan: &ExecutionPlan) -> ExecutionResult {
|
||||||
|
ExecutionResult {
|
||||||
|
request_id: plan.request_id.clone(),
|
||||||
|
candidate_id: plan.candidate_id.clone(),
|
||||||
|
status_code: 200,
|
||||||
|
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||||
|
body: Some(ResponseBody {
|
||||||
|
json_body: Some(json!({
|
||||||
|
"predictions": [
|
||||||
|
{
|
||||||
|
"embeddings": {
|
||||||
|
"values": [0.1, 0.2, 0.3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployedModelId": "gemini-embedding-2"
|
||||||
|
})),
|
||||||
|
body_bytes_b64: None,
|
||||||
|
}),
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn gemini_batch_embedding_execution_result(plan: &ExecutionPlan) -> ExecutionResult {
|
fn gemini_batch_embedding_execution_result(plan: &ExecutionPlan) -> ExecutionResult {
|
||||||
ExecutionResult {
|
ExecutionResult {
|
||||||
request_id: plan.request_id.clone(),
|
request_id: plan.request_id.clone(),
|
||||||
@@ -636,7 +661,7 @@ async fn embeddings_route_converts_openai_payload_to_vertex_gemini_embedding_pro
|
|||||||
assert_eq!(endpoint_signature.as_deref(), Some("openai:embedding"));
|
assert_eq!(endpoint_signature.as_deref(), Some("openai:embedding"));
|
||||||
let payload: serde_json::Value = serde_json::from_str(&body_text).expect("body should parse");
|
let payload: serde_json::Value = serde_json::from_str(&body_text).expect("body should parse");
|
||||||
assert_eq!(payload["object"], "list");
|
assert_eq!(payload["object"], "list");
|
||||||
assert_eq!(payload["model"], "gemini-embedding-2-preview");
|
assert_eq!(payload["model"], "gemini-embedding-2");
|
||||||
assert_eq!(payload["data"][0]["embedding"], json!([0.1, 0.2, 0.3]));
|
assert_eq!(payload["data"][0]["embedding"], json!([0.1, 0.2, 0.3]));
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
|
|||||||
@@ -736,7 +736,7 @@ const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
|||||||
AdminApiFormatDefinition {
|
AdminApiFormatDefinition {
|
||||||
value: "gemini:embedding",
|
value: "gemini:embedding",
|
||||||
label: "Gemini Embedding",
|
label: "Gemini Embedding",
|
||||||
default_path: "/v1/embeddings",
|
default_path: "/v1beta/models/{model}:{action}",
|
||||||
aliases: &["gemini_embedding"],
|
aliases: &["gemini_embedding"],
|
||||||
},
|
},
|
||||||
AdminApiFormatDefinition {
|
AdminApiFormatDefinition {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let embeddings = if let Some(values) = body
|
let embeddings = if let Some(raw_embeddings) = vertex_predict_embeddings(body) {
|
||||||
|
raw_embeddings
|
||||||
|
} else if let Some(values) = body
|
||||||
.get("embedding")
|
.get("embedding")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.and_then(|embedding| embedding.get("values"))
|
.and_then(|embedding| embedding.get("values"))
|
||||||
@@ -56,6 +58,7 @@ pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
|
|||||||
model: body
|
model: body
|
||||||
.get("model")
|
.get("model")
|
||||||
.or_else(|| body.get("modelVersion"))
|
.or_else(|| body.get("modelVersion"))
|
||||||
|
.or_else(|| body.get("deployedModelId"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -69,14 +72,36 @@ pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
|
|||||||
"responseId",
|
"responseId",
|
||||||
"model",
|
"model",
|
||||||
"modelVersion",
|
"modelVersion",
|
||||||
|
"deployedModelId",
|
||||||
"embedding",
|
"embedding",
|
||||||
"embeddings",
|
"embeddings",
|
||||||
|
"predictions",
|
||||||
"usageMetadata",
|
"usageMetadata",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vertex_predict_embeddings(
|
||||||
|
body: &serde_json::Map<String, Value>,
|
||||||
|
) -> Option<Vec<CanonicalEmbedding>> {
|
||||||
|
let predictions = body.get("predictions")?.as_array()?;
|
||||||
|
predictions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| {
|
||||||
|
let item_object = item.as_object()?;
|
||||||
|
let embedding_object = item_object.get("embeddings")?.as_object()?;
|
||||||
|
let values = embedding_object.get("values")?.as_array()?;
|
||||||
|
Some(CanonicalEmbedding {
|
||||||
|
index,
|
||||||
|
embedding: embedding_values(values)?,
|
||||||
|
extensions: namespace_extensions("vertex", item_object, &["embeddings"]),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn embedding_values(values: &[Value]) -> Option<Vec<f64>> {
|
fn embedding_values(values: &[Value]) -> Option<Vec<f64>> {
|
||||||
values.iter().map(Value::as_f64).collect()
|
values.iter().map(Value::as_f64).collect()
|
||||||
}
|
}
|
||||||
@@ -105,4 +130,29 @@ mod tests {
|
|||||||
assert_eq!(usage.input_tokens, 4);
|
assert_eq!(usage.input_tokens, 4);
|
||||||
assert_eq!(usage.total_tokens, 4);
|
assert_eq!(usage.total_tokens, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_vertex_predict_embedding_response() {
|
||||||
|
let body = json!({
|
||||||
|
"predictions": [
|
||||||
|
{
|
||||||
|
"embeddings": {
|
||||||
|
"values": [0.1, 0.2, 0.3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"embeddings": {
|
||||||
|
"values": [0.4, 0.5, 0.6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployedModelId": "gemini-embedding-2"
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed = from(&body).expect("response should parse");
|
||||||
|
|
||||||
|
assert_eq!(parsed.model, "gemini-embedding-2");
|
||||||
|
assert_eq!(parsed.embeddings[0].embedding, vec![0.1, 0.2, 0.3]);
|
||||||
|
assert_eq!(parsed.embeddings[1].embedding, vec![0.4, 0.5, 0.6]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,21 @@ impl CandidateFailureDiagnostic {
|
|||||||
.source(source)
|
.source(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn request_conversion_failed(
|
||||||
|
client_api_format: impl Into<String>,
|
||||||
|
provider_api_format: impl Into<String>,
|
||||||
|
source: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
CandidateFailureDiagnosticKind::RequestConversion,
|
||||||
|
"$",
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.formats(client_api_format, provider_api_format)
|
||||||
|
.source(source)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn envelope_build_failed(
|
pub fn envelope_build_failed(
|
||||||
client_api_format: impl Into<String>,
|
client_api_format: impl Into<String>,
|
||||||
provider_api_format: impl Into<String>,
|
provider_api_format: impl Into<String>,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod oauth_refresh;
|
|||||||
mod openai_image;
|
mod openai_image;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
pub mod provider_types;
|
pub mod provider_types;
|
||||||
|
mod request_body;
|
||||||
mod request_url;
|
mod request_url;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod same_format_provider;
|
pub mod same_format_provider;
|
||||||
@@ -69,6 +70,9 @@ pub use policy::{
|
|||||||
local_standard_transport_unsupported_reason_with_network, supports_local_gemini_transport,
|
local_standard_transport_unsupported_reason_with_network, supports_local_gemini_transport,
|
||||||
supports_local_gemini_transport_with_network, supports_local_standard_transport,
|
supports_local_gemini_transport_with_network, supports_local_standard_transport,
|
||||||
};
|
};
|
||||||
|
pub use request_body::{
|
||||||
|
apply_transport_request_body_semantics, TransportRequestBodySemanticsError,
|
||||||
|
};
|
||||||
pub use request_url::{
|
pub use request_url::{
|
||||||
build_cross_format_openai_chat_upstream_url, build_cross_format_openai_responses_upstream_url,
|
build_cross_format_openai_chat_upstream_url, build_cross_format_openai_responses_upstream_url,
|
||||||
build_kiro_cross_format_upstream_url, build_local_openai_chat_upstream_url,
|
build_kiro_cross_format_upstream_url, build_local_openai_chat_upstream_url,
|
||||||
|
|||||||
415
crates/aether-provider-transport/src/request_body.rs
Normal file
415
crates/aether-provider-transport/src/request_body.rs
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||||
|
use crate::vertex::is_vertex_transport_context;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TransportRequestBodySemanticsError {
|
||||||
|
message: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportRequestBodySemanticsError {
|
||||||
|
const fn new(message: &'static str) -> Self {
|
||||||
|
Self { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn message(&self) -> &'static str {
|
||||||
|
self.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TransportRequestBodySemanticsError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TransportRequestBodySemanticsError {}
|
||||||
|
|
||||||
|
pub fn apply_transport_request_body_semantics(
|
||||||
|
provider_request_body: &mut Value,
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
provider_api_format: &str,
|
||||||
|
) -> Result<(), TransportRequestBodySemanticsError> {
|
||||||
|
let provider_api_format = aether_ai_formats::normalize_api_format_alias(provider_api_format);
|
||||||
|
if provider_api_format == "gemini:embedding" && is_vertex_transport_context(transport) {
|
||||||
|
apply_vertex_gemini_embedding_body_semantics(provider_request_body)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_vertex_gemini_embedding_body_semantics(
|
||||||
|
provider_request_body: &mut Value,
|
||||||
|
) -> Result<(), TransportRequestBodySemanticsError> {
|
||||||
|
let object = provider_request_body.as_object_mut().ok_or_else(|| {
|
||||||
|
TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding request body must be a JSON object",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if object.contains_key("instances") {
|
||||||
|
validate_existing_vertex_predict_body(object)?;
|
||||||
|
object.remove("model");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = build_vertex_predict_body_from_gemini_embedding_object(object)?;
|
||||||
|
*object = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_vertex_predict_body_from_gemini_embedding_object(
|
||||||
|
object: &Map<String, Value>,
|
||||||
|
) -> Result<Map<String, Value>, TransportRequestBodySemanticsError> {
|
||||||
|
if let Some(requests) = object.get("requests") {
|
||||||
|
if object.keys().any(|key| key != "requests") {
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding batch body cannot mix requests with other top-level fields",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let request_items = requests.as_array().ok_or_else(|| {
|
||||||
|
TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding requests must be an array",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let request_objects = request_items
|
||||||
|
.iter()
|
||||||
|
.map(Value::as_object)
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding requests must be an array of objects",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if request_objects.is_empty() {
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding requests must contain at least one item",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return build_vertex_predict_body_from_gemini_embedding_items(&request_objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
build_vertex_predict_body_from_gemini_embedding_items(&[object])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_vertex_predict_body_from_gemini_embedding_items(
|
||||||
|
items: &[&Map<String, Value>],
|
||||||
|
) -> Result<Map<String, Value>, TransportRequestBodySemanticsError> {
|
||||||
|
if items.iter().any(|item| {
|
||||||
|
item.keys().any(|key| {
|
||||||
|
!matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"model"
|
||||||
|
| "content"
|
||||||
|
| "taskType"
|
||||||
|
| "title"
|
||||||
|
| "outputDimensionality"
|
||||||
|
| "autoTruncate"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding body contains fields that cannot be mapped to predict instances",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances = items
|
||||||
|
.iter()
|
||||||
|
.map(|item| build_vertex_predict_instance(item))
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding body must contain text content parts",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if instances.is_empty() {
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding body must contain at least one instance",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = Map::new();
|
||||||
|
output.insert("instances".to_string(), Value::Array(instances));
|
||||||
|
|
||||||
|
let mut parameters = Map::new();
|
||||||
|
insert_shared_parameter(items, &mut parameters, "outputDimensionality")?;
|
||||||
|
insert_shared_parameter(items, &mut parameters, "autoTruncate")?;
|
||||||
|
if !parameters.is_empty() {
|
||||||
|
output.insert("parameters".to_string(), Value::Object(parameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_existing_vertex_predict_body(
|
||||||
|
object: &Map<String, Value>,
|
||||||
|
) -> Result<(), TransportRequestBodySemanticsError> {
|
||||||
|
if object
|
||||||
|
.keys()
|
||||||
|
.any(|key| !matches!(key.as_str(), "model" | "instances" | "parameters"))
|
||||||
|
{
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding predict body contains unsupported top-level fields",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(instances) = object.get("instances").and_then(Value::as_array) else {
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding predict body must contain an instances array",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if instances.is_empty() {
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding predict body must contain at least one instance",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if object
|
||||||
|
.get("parameters")
|
||||||
|
.is_some_and(|parameters| !parameters.is_object())
|
||||||
|
{
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding predict parameters must be an object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_vertex_predict_instance(item: &Map<String, Value>) -> Option<Value> {
|
||||||
|
let content = gemini_embedding_content_text(item.get("content")?)?;
|
||||||
|
let mut instance = Map::new();
|
||||||
|
instance.insert("content".to_string(), Value::String(content));
|
||||||
|
if let Some(task_type) = item.get("taskType") {
|
||||||
|
instance.insert(
|
||||||
|
"task_type".to_string(),
|
||||||
|
Value::String(task_type.as_str()?.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(title) = item.get("title") {
|
||||||
|
instance.insert(
|
||||||
|
"title".to_string(),
|
||||||
|
Value::String(title.as_str()?.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Value::Object(instance))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gemini_embedding_content_text(content: &Value) -> Option<String> {
|
||||||
|
let parts = content
|
||||||
|
.as_object()?
|
||||||
|
.get("parts")?
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| part.as_object()?.get("text")?.as_str())
|
||||||
|
.filter(|text| !text.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(parts.join(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_shared_parameter(
|
||||||
|
items: &[&Map<String, Value>],
|
||||||
|
parameters: &mut Map<String, Value>,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<(), TransportRequestBodySemanticsError> {
|
||||||
|
let mut value: Option<Value> = None;
|
||||||
|
for item in items {
|
||||||
|
let Some(next) = item.get(key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match &value {
|
||||||
|
Some(current) if current != next => {
|
||||||
|
return Err(TransportRequestBodySemanticsError::new(
|
||||||
|
"Vertex Gemini embedding batch items must use the same shared parameters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => value = Some(next.clone()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = value {
|
||||||
|
parameters.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::apply_transport_request_body_semantics;
|
||||||
|
use crate::snapshot::{
|
||||||
|
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||||
|
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn sample_transport(provider_type: &str, base_url: &str) -> GatewayProviderTransportSnapshot {
|
||||||
|
GatewayProviderTransportSnapshot {
|
||||||
|
provider: GatewayProviderTransportProvider {
|
||||||
|
id: "provider-1".to_string(),
|
||||||
|
name: "provider".to_string(),
|
||||||
|
provider_type: provider_type.to_string(),
|
||||||
|
website: None,
|
||||||
|
is_active: true,
|
||||||
|
keep_priority_on_conversion: false,
|
||||||
|
enable_format_conversion: true,
|
||||||
|
concurrent_limit: None,
|
||||||
|
max_retries: None,
|
||||||
|
proxy: None,
|
||||||
|
request_timeout_secs: None,
|
||||||
|
stream_first_byte_timeout_secs: None,
|
||||||
|
config: None,
|
||||||
|
},
|
||||||
|
endpoint: GatewayProviderTransportEndpoint {
|
||||||
|
id: "endpoint-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
api_format: "gemini:embedding".to_string(),
|
||||||
|
api_family: Some("gemini".to_string()),
|
||||||
|
endpoint_kind: Some("embedding".to_string()),
|
||||||
|
is_active: true,
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
header_rules: None,
|
||||||
|
body_rules: None,
|
||||||
|
max_retries: None,
|
||||||
|
custom_path: None,
|
||||||
|
config: None,
|
||||||
|
format_acceptance_config: None,
|
||||||
|
proxy: None,
|
||||||
|
},
|
||||||
|
key: GatewayProviderTransportKey {
|
||||||
|
id: "key-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
name: "key".to_string(),
|
||||||
|
auth_type: "api_key".to_string(),
|
||||||
|
is_active: true,
|
||||||
|
api_formats: Some(vec!["gemini:embedding".to_string()]),
|
||||||
|
auth_type_by_format: None,
|
||||||
|
allow_auth_channel_mismatch_formats: None,
|
||||||
|
allowed_models: None,
|
||||||
|
capabilities: None,
|
||||||
|
rate_multipliers: None,
|
||||||
|
global_priority_by_format: None,
|
||||||
|
expires_at_unix_secs: None,
|
||||||
|
proxy: None,
|
||||||
|
fingerprint: None,
|
||||||
|
decrypted_api_key: "secret".to_string(),
|
||||||
|
decrypted_auth_config: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_gemini_embedding_single_body_uses_predict_contract() {
|
||||||
|
let transport = sample_transport("vertex_ai", "https://aiplatform.googleapis.com");
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gemini-embedding-2",
|
||||||
|
"content": {"parts": [{"text": "hello"}]},
|
||||||
|
"taskType": "RETRIEVAL_QUERY",
|
||||||
|
"outputDimensionality": 768
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_transport_request_body_semantics(&mut body, &transport, "gemini:embedding")
|
||||||
|
.expect("body semantics should apply");
|
||||||
|
|
||||||
|
assert!(body.get("model").is_none());
|
||||||
|
assert!(body.get("content").is_none());
|
||||||
|
assert_eq!(body["instances"][0]["content"], "hello");
|
||||||
|
assert_eq!(body["instances"][0]["task_type"], "RETRIEVAL_QUERY");
|
||||||
|
assert_eq!(body["parameters"]["outputDimensionality"], 768);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_api_embedding_single_body_keeps_model_for_developer_api() {
|
||||||
|
let transport =
|
||||||
|
sample_transport("gemini", "https://generativelanguage.googleapis.com/v1beta");
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gemini-embedding-2",
|
||||||
|
"content": {"parts": [{"text": "hello"}]}
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_transport_request_body_semantics(&mut body, &transport, "gemini:embedding")
|
||||||
|
.expect("developer API body should pass through");
|
||||||
|
|
||||||
|
assert_eq!(body["model"], "gemini-embedding-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_gemini_embedding_batch_body_uses_predict_instances() {
|
||||||
|
let transport = sample_transport("vertex_ai", "https://aiplatform.googleapis.com");
|
||||||
|
let mut body = json!({
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"model": "models/gemini-embedding-2",
|
||||||
|
"content": {"parts": [{"text": "hello"}]}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "models/gemini-embedding-2",
|
||||||
|
"content": {"parts": [{"text": "world"}]}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_transport_request_body_semantics(&mut body, &transport, "gemini:embedding")
|
||||||
|
.expect("batch body semantics should apply");
|
||||||
|
|
||||||
|
assert!(body.get("requests").is_none());
|
||||||
|
assert_eq!(body["instances"][0]["content"], "hello");
|
||||||
|
assert_eq!(body["instances"][1]["content"], "world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_gemini_embedding_existing_predict_body_removes_duplicate_model() {
|
||||||
|
let transport = sample_transport("vertex_ai", "https://aiplatform.googleapis.com");
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gemini-embedding-2",
|
||||||
|
"instances": [
|
||||||
|
{"content": "hello", "task_type": "RETRIEVAL_QUERY"}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"outputDimensionality": 768
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_transport_request_body_semantics(&mut body, &transport, "gemini:embedding")
|
||||||
|
.expect("existing predict body should be accepted");
|
||||||
|
|
||||||
|
assert!(body.get("model").is_none());
|
||||||
|
assert_eq!(body["instances"][0]["content"], "hello");
|
||||||
|
assert_eq!(body["parameters"]["outputDimensionality"], 768);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_gemini_embedding_existing_predict_body_rejects_unconsumed_fields() {
|
||||||
|
let transport = sample_transport("vertex_ai", "https://aiplatform.googleapis.com");
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gemini-embedding-2",
|
||||||
|
"instances": [
|
||||||
|
{"content": "hello"}
|
||||||
|
],
|
||||||
|
"input": "this field would not be consumed by Vertex predict"
|
||||||
|
});
|
||||||
|
|
||||||
|
let error =
|
||||||
|
apply_transport_request_body_semantics(&mut body, &transport, "gemini:embedding")
|
||||||
|
.expect_err("predict body must not carry unconsumed OpenAI fields");
|
||||||
|
|
||||||
|
assert!(error.message().contains("unsupported top-level fields"));
|
||||||
|
assert!(body.get("model").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_gemini_embedding_rejects_unconverted_openai_body() {
|
||||||
|
let transport = sample_transport("vertex_ai", "https://aiplatform.googleapis.com");
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "gemini-embedding-2",
|
||||||
|
"input": "hello"
|
||||||
|
});
|
||||||
|
|
||||||
|
let error =
|
||||||
|
apply_transport_request_body_semantics(&mut body, &transport, "gemini:embedding")
|
||||||
|
.expect_err("OpenAI embedding body must not be sent to Vertex native predict");
|
||||||
|
|
||||||
|
assert!(error.message().contains("cannot be mapped"));
|
||||||
|
assert!(body.get("input").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ use crate::url::{
|
|||||||
use crate::vertex::{
|
use crate::vertex::{
|
||||||
build_vertex_api_key_gemini_content_url, build_vertex_api_key_gemini_embedding_url,
|
build_vertex_api_key_gemini_content_url, build_vertex_api_key_gemini_embedding_url,
|
||||||
build_vertex_service_account_gemini_content_url,
|
build_vertex_service_account_gemini_content_url,
|
||||||
build_vertex_service_account_gemini_embedding_url, is_vertex_transport_context,
|
build_vertex_service_account_gemini_embedding_url, resolve_local_vertex_api_key_query_auth,
|
||||||
resolve_local_vertex_api_key_query_auth, resolve_local_vertex_service_account_auth_config,
|
resolve_local_vertex_service_account_auth_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -68,13 +68,6 @@ fn build_transport_request_url_inner(
|
|||||||
let provider_api_format = params.provider_api_format.trim().to_ascii_lowercase();
|
let provider_api_format = params.provider_api_format.trim().to_ascii_lowercase();
|
||||||
let normalized_provider_api_format =
|
let normalized_provider_api_format =
|
||||||
aether_ai_formats::normalize_api_format_alias(&provider_api_format);
|
aether_ai_formats::normalize_api_format_alias(&provider_api_format);
|
||||||
if normalized_provider_api_format == "gemini:embedding"
|
|
||||||
&& gemini_embedding_batch
|
|
||||||
&& is_vertex_transport_context(transport)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(url) = build_transport_hook_url(transport, params) {
|
if let Some(url) = build_transport_hook_url(transport, params) {
|
||||||
return Some(url);
|
return Some(url);
|
||||||
}
|
}
|
||||||
@@ -696,12 +689,12 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
url,
|
url,
|
||||||
"https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/publishers/google/models/gemini-embedding-2:embedContent?foo=bar"
|
"https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/publishers/google/models/gemini-embedding-2:predict?foo=bar"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vertex_gemini_embedding_batch_request_does_not_use_gemini_api_batch_endpoint() {
|
fn vertex_gemini_embedding_batch_request_uses_vertex_predict_endpoint() {
|
||||||
let mut transport = sample_transport(
|
let mut transport = sample_transport(
|
||||||
"vertex_ai",
|
"vertex_ai",
|
||||||
"gemini:embedding",
|
"gemini:embedding",
|
||||||
@@ -729,18 +722,23 @@ mod tests {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(build_transport_request_url_for_request_body(
|
assert_eq!(
|
||||||
&transport,
|
build_transport_request_url_for_request_body(
|
||||||
TransportRequestUrlParams {
|
&transport,
|
||||||
provider_api_format: "gemini:embedding",
|
TransportRequestUrlParams {
|
||||||
mapped_model: Some("gemini-embedding-2"),
|
provider_api_format: "gemini:embedding",
|
||||||
upstream_is_stream: false,
|
mapped_model: Some("gemini-embedding-2"),
|
||||||
request_query: None,
|
upstream_is_stream: false,
|
||||||
kiro_api_region: None,
|
request_query: None,
|
||||||
},
|
kiro_api_region: None,
|
||||||
Some(&batch_body),
|
},
|
||||||
)
|
Some(&batch_body),
|
||||||
.is_none());
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some(
|
||||||
|
"https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/publishers/google/models/gemini-embedding-2:predict"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub fn build_vertex_api_key_gemini_embedding_url(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
request_query: Option<&str>,
|
request_query: Option<&str>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
build_vertex_api_key_google_model_url(model, "embedContent", false, api_key, request_query)
|
build_vertex_api_key_google_model_url(model, "predict", false, api_key, request_query)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_vertex_service_account_gemini_content_url(
|
pub fn build_vertex_service_account_gemini_content_url(
|
||||||
@@ -64,7 +64,7 @@ pub fn build_vertex_service_account_gemini_embedding_url(
|
|||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
build_vertex_service_account_google_model_url(
|
build_vertex_service_account_google_model_url(
|
||||||
model,
|
model,
|
||||||
"embedContent",
|
"predict",
|
||||||
false,
|
false,
|
||||||
auth_config,
|
auth_config,
|
||||||
request_query,
|
request_query,
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ Aether 还必须区分 Google 官方的 OpenAI-compatible 表面。它们使用
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Generate Content | `models/{model}:generateContent` | `projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent` | 两边都支持,但 URL 构造不同 |
|
| Generate Content | `models/{model}:generateContent` | `projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent` | 两边都支持,但 URL 构造不同 |
|
||||||
| Stream Generate Content | `models/{model}:streamGenerateContent?alt=sse` | `projects/{project}/locations/{location}/publishers/google/models/{model}:streamGenerateContent?alt=sse` | 两边都支持,但 URL 构造不同 |
|
| Stream Generate Content | `models/{model}:streamGenerateContent?alt=sse` | `projects/{project}/locations/{location}/publishers/google/models/{model}:streamGenerateContent?alt=sse` | 两边都支持,但 URL 构造不同 |
|
||||||
| Single Embedding | `models/{model}:embedContent` | `projects/{project}/locations/{location}/publishers/google/models/{model}:embedContent` | 两边都支持,但 URL 构造不同 |
|
| Single Embedding | `models/{model}:embedContent` | `projects/{project}/locations/{location}/publishers/google/models/{model}:predict` | Vertex 文本 embedding 使用 Predict contract:`instances[]` + `parameters` |
|
||||||
| Batch Embedding | `models/{model}:batchEmbedContents` | 官方 REST reference 当前未提供同名 Vertex 方法 | Developer API 可批量;Vertex 必须显式拒绝或拆分,不得伪装成 Vertex batch |
|
| Batch Embedding | `models/{model}:batchEmbedContents` | 同一个 `:predict`,由 `instances[]` 表达多输入 | Aether 不切到 Developer API;模型自身的批量限制由 Vertex 明确返回 |
|
||||||
|
|
||||||
工程不变量:
|
工程不变量:
|
||||||
|
|
||||||
1. 默认 Gemini provider 只能生成 Gemini Developer API URL,不得因为模型名是 Gemini 就走 Vertex。
|
1. 默认 Gemini provider 只能生成 Gemini Developer API URL,不得因为模型名是 Gemini 就走 Vertex。
|
||||||
2. `provider_type = vertex_ai` 或明确的 Vertex auth/host 只能生成 Vertex URL,不得回退到 Gemini Developer API URL。
|
2. `provider_type = vertex_ai` 或明确的 Vertex auth/host 只能生成 Vertex URL,不得回退到 Gemini Developer API URL。
|
||||||
3. Vertex embedding 批量请求在没有官方 batch 端点前不能静默改走 `generativelanguage.googleapis.com:batchEmbedContents`。
|
3. Vertex embedding 请求必须使用 Vertex Predict contract,不得把 Developer API 的 `model/content/requests` body 原样发给 `:predict`。
|
||||||
4. 任何“不支持”的情况必须在调度/URL 构造阶段显式暴露为不可用,不能伪成功。
|
4. 任何“不支持”的情况必须在调度/URL 构造阶段显式暴露为不可用,不能伪成功。
|
||||||
5. Provider 模板、runtime policy、URL builder、conversion policy、测试连接、live DB reconciliation 必须消费同一个语义模型。
|
5. Provider 模板、runtime policy、URL builder、conversion policy、测试连接、live DB reconciliation 必须消费同一个语义模型。
|
||||||
6. Google 官方 OpenAI-compatible root 已经包含 API root,Aether 不得额外拼接 `/v1`,否则会生成 `.../openai/v1/...` 或 `.../endpoints/openapi/v1/...` 这类错误 URL。
|
6. Google 官方 OpenAI-compatible root 已经包含 API root,Aether 不得额外拼接 `/v1`,否则会生成 `.../openai/v1/...` 或 `.../endpoints/openapi/v1/...` 这类错误 URL。
|
||||||
@@ -77,6 +77,7 @@ Vertex AI 的 Gemini API REST reference 使用 `aiplatform.googleapis.com` 或 r
|
|||||||
- Vertex AI Generate Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/generateContent>
|
- Vertex AI Generate Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/generateContent>
|
||||||
- Vertex AI Stream Generate Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/streamGenerateContent>
|
- Vertex AI Stream Generate Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/streamGenerateContent>
|
||||||
- Vertex AI Embed Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/embedContent>
|
- Vertex AI Embed Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/embedContent>
|
||||||
|
- Vertex AI Predict REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/predict>
|
||||||
- Vertex AI REST resources: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models>
|
- Vertex AI REST resources: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models>
|
||||||
- Vertex AI text embeddings API: <https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api>
|
- Vertex AI text embeddings API: <https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api>
|
||||||
- Vertex AI OpenAI compatibility: <https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai>
|
- Vertex AI OpenAI compatibility: <https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai>
|
||||||
@@ -88,7 +89,9 @@ Vertex AI 的 Gemini API REST reference 使用 `aiplatform.googleapis.com` 或 r
|
|||||||
- 对 `global` location,可使用 `https://aiplatform.googleapis.com/v1/projects/{project}/locations/global/...`
|
- 对 `global` location,可使用 `https://aiplatform.googleapis.com/v1/projects/{project}/locations/global/...`
|
||||||
- Vertex API key 路径可走:
|
- Vertex API key 路径可走:
|
||||||
- `https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key=...`
|
- `https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key=...`
|
||||||
- Vertex REST reference 当前列出 `embedContent`,未列出 `batchEmbedContents`。因此 Aether 不得自行构造 Vertex batch endpoint。
|
- Vertex 文本 embedding API 文档使用 `:predict`,请求体是 `instances[]`,可选参数在 `parameters` 下;响应是 `predictions[].embeddings.values`。
|
||||||
|
- Vertex REST reference 也列出 `embedContent`,但 Aether 当前 text embedding 主链使用 text embeddings guide 和 Predict API 的 contract。
|
||||||
|
- Vertex `instances[]` 是在线 Predict 请求体,不等同于异步 batch prediction job。模型级输入数量限制由 Vertex 返回;Aether 不把超出限制的请求静默改走其他产品面。
|
||||||
- Vertex OpenAI-compatible root 是 `/v1/projects/{project}/locations/{location}/endpoints/openapi`,其 OpenAI path 直接挂在这个 root 之后。
|
- Vertex OpenAI-compatible root 是 `/v1/projects/{project}/locations/{location}/endpoints/openapi`,其 OpenAI path 直接挂在这个 root 之后。
|
||||||
- 自定义 Vertex OpenAI-compatible endpoint 可以使用 service account token 刷新,但只有 base URL 明确落在 `/endpoints/openapi` 时才能启用该 Vertex auth 语义。普通 `aiplatform.googleapis.com` + `openai:*` 不能被误判成 Vertex OpenAI compatibility。
|
- 自定义 Vertex OpenAI-compatible endpoint 可以使用 service account token 刷新,但只有 base URL 明确落在 `/endpoints/openapi` 时才能启用该 Vertex auth 语义。普通 `aiplatform.googleapis.com` + `openai:*` 不能被误判成 Vertex OpenAI compatibility。
|
||||||
|
|
||||||
@@ -175,15 +178,24 @@ Developer API 的批量 embedding 支持顶层 `requests[]`。Aether 可以继
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `gemini:generate_content` | false | 不适用 | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent` |
|
| `gemini:generate_content` | false | 不适用 | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent` |
|
||||||
| `gemini:generate_content` | true | 不适用 | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:streamGenerateContent?alt=sse` |
|
| `gemini:generate_content` | true | 不适用 | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:streamGenerateContent?alt=sse` |
|
||||||
| `gemini:embedding` | false | false | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:embedContent` |
|
| `gemini:embedding` | false | false | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:predict` |
|
||||||
| `gemini:embedding` | false | true | unsupported, fail closed | 官方 REST reference 未提供 Vertex batch method |
|
| `gemini:embedding` | false | true | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:predict` |
|
||||||
|
|
||||||
Vertex 单条 embedding 的模型由 URL path 承载,body 不得重复携带顶层 `model` 字段,否则会触发 Vertex `oneof field '_model' is already set` 一类错误。body 应只保留 `content` 与显式 embedding options。批量请求如果来自 OpenAI embedding 的数组输入,在没有官方 Vertex batch 端点前有两个可选工程策略:
|
Vertex text embedding 的模型由 URL path 承载,body 不得重复携带顶层 `model` 字段,否则会触发 Vertex `oneof field '_model' is already set` 一类错误。Aether 在 Vertex transport context 下必须把 Gemini Developer API embedding body 转成 Predict body:
|
||||||
|
|
||||||
1. 第一阶段 fail closed:返回明确 unsupported,不让它伪成功。
|
```json
|
||||||
2. 第二阶段显式 fan-out:Aether 自己把数组拆成多条 Vertex `embedContent` 调用,再按 OpenAI embedding 响应格式合并。
|
{
|
||||||
|
"instances": [
|
||||||
|
{ "content": "text", "task_type": "RETRIEVAL_QUERY", "title": "optional" }
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"outputDimensionality": 768,
|
||||||
|
"autoTruncate": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
本次先做第一阶段,因为它不会隐藏批量语义差异;后续如果实现 fan-out,必须有单独设计与负载控制,不得把 fan-out 塞进 URL builder。
|
如果输入已经是 Predict body,Aether 只移除重复的顶层 `model`。如果输入仍是 OpenAI body 或无法确定可转换,调度阶段必须显式失败,不得把未转换 body 发到 Vertex native endpoint。
|
||||||
|
|
||||||
### Google OpenAI-Compatible URL
|
### Google OpenAI-Compatible URL
|
||||||
|
|
||||||
@@ -215,10 +227,11 @@ OpenAI-compatible URL 属于显式 passthrough root,不参与 native Gemini UR
|
|||||||
|
|
||||||
设计要求:
|
设计要求:
|
||||||
|
|
||||||
1. 该 converter 可以继续生成 Gemini batch body,但 transport 层必须知道这对 Vertex 不可直接消费。
|
1. 该 converter 可以继续生成 Gemini Developer API 的 `embedContent` / `batchEmbedContents` body。
|
||||||
2. 如果未来实现 Vertex fan-out,fan-out 应发生在 gateway execution 层,而不是让 `request_url` 或 body converter 假装一个 Vertex batch endpoint 存在。
|
2. Transport 层必须在 Vertex context 下把该 body 转成 Predict body,并在无法转换时 fail closed。
|
||||||
3. 所有 taskType / outputDimensionality 必须保持显式传递;不得默认注入会改变语义的 task 或维度。
|
3. 所有 taskType / outputDimensionality 必须保持显式传递;不得默认注入会改变语义的 task 或维度。
|
||||||
4. Developer API 单条 embedding body 可以保留 `model`;Vertex 单条 embedding 在 gateway transport 语义层必须删除顶层 `model`,因为 Vertex 模型已在 path 中指定。
|
4. Vertex Predict 的 `instances[]` 只能表示在线 Predict 请求的一次调用;它不是异步 batch prediction job,也不是 Developer API `batchEmbedContents` 的静默替身。
|
||||||
|
5. Developer API 单条 embedding body 可以保留 `model`;Vertex 单条 embedding 在 gateway transport 语义层必须删除顶层 `model`,因为 Vertex 模型已在 path 中指定。
|
||||||
|
|
||||||
### 格式转换矩阵
|
### 格式转换矩阵
|
||||||
|
|
||||||
@@ -229,17 +242,17 @@ OpenAI-compatible URL 属于显式 passthrough root,不参与 native Gemini UR
|
|||||||
|
|
||||||
`gemini:generate_content` 在 Developer API 与 Vertex AI 上使用同一 Gemini generate-content body 形态,因此格式转换器不应区分这两个产品面。产品面差异只留给 URL/auth 层处理。
|
`gemini:generate_content` 在 Developer API 与 Vertex AI 上使用同一 Gemini generate-content body 形态,因此格式转换器不应区分这两个产品面。产品面差异只留给 URL/auth 层处理。
|
||||||
|
|
||||||
`gemini:embedding` 同样应使用同一 Gemini embedding body 形态,但 URL 层必须区分单条和批量能力。
|
`gemini:embedding` 在格式层仍先表达为 Gemini Developer API 的 embedding body。进入 Vertex transport context 时,transport 层再把它收敛到 Vertex Predict body。这样格式转换器不需要知道认证方式,URL/body transport 也不会把 Developer API body 原样发给 Vertex。
|
||||||
|
|
||||||
| 客户端格式 | Provider 格式 | Developer API | Vertex AI | 处理要求 |
|
| 客户端格式 | Provider 格式 | Developer API | Vertex AI | 处理要求 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `openai:chat` | `gemini:generate_content` | 支持 | 支持 | OpenAI chat -> Gemini contents / generationConfig |
|
| `openai:chat` | `gemini:generate_content` | 支持 | 支持 | OpenAI chat -> Gemini contents / generationConfig |
|
||||||
| `gemini:generate_content` | `openai:chat` | 支持 | 支持 | Gemini contents -> OpenAI messages |
|
| `gemini:generate_content` | `openai:chat` | 支持 | 支持 | Gemini contents -> OpenAI messages |
|
||||||
| `openai:embedding` | `gemini:embedding` 单条 | 支持 | 支持 | OpenAI input string 或单项数组 -> Gemini `embedContent` body |
|
| `openai:embedding` | `gemini:embedding` 单条 | 支持 | 支持 | OpenAI input string 或单项数组 -> Gemini `embedContent` body;Vertex transport 再转 `instances[]` |
|
||||||
| `openai:embedding` | `gemini:embedding` 多条 | 支持 | fail closed | Developer API -> `batchEmbedContents`; Vertex 无官方 batch endpoint |
|
| `openai:embedding` | `gemini:embedding` 多条 | 支持 | 支持于 transport 层 | Developer API -> `batchEmbedContents`;Vertex transport -> Predict `instances[]`,模型限制由 Vertex 返回 |
|
||||||
| `gemini:embedding` 单条 | `openai:embedding` | 支持 | 支持 | Gemini `content.parts[].text` -> OpenAI `input` string |
|
| `gemini:embedding` 单条 | `openai:embedding` | 支持 | 支持 | Gemini `content.parts[].text` -> OpenAI `input` string |
|
||||||
| `gemini:embedding` 批量 | `openai:embedding` | 支持 | 支持于格式层;执行层仍受 Vertex batch 限制 | Gemini `requests[]` -> OpenAI `input[]` |
|
| `gemini:embedding` 批量 | `openai:embedding` | 支持 | 支持 | Gemini `requests[]` -> OpenAI `input[]`;Vertex Predict response 同样可转 |
|
||||||
| `gemini:embedding` response | `openai:embedding` response | 支持 | 支持 | Gemini `embedding.values` / `embeddings[].values` -> OpenAI `data[].embedding` |
|
| `gemini:embedding` response | `openai:embedding` response | 支持 | 支持 | Gemini `embedding.values` / `embeddings[].values` / Vertex `predictions[].embeddings.values` -> OpenAI `data[].embedding` |
|
||||||
| `openai:embedding` response | `gemini:embedding` response | 支持 | 支持于格式层 | OpenAI `data[]` -> Gemini single `embedding` 或 batch `embeddings[]` |
|
| `openai:embedding` response | `gemini:embedding` response | 支持 | 支持于格式层 | OpenAI `data[]` -> Gemini single `embedding` 或 batch `embeddings[]` |
|
||||||
| `openai:chat` | `openai:chat` on Google OpenAI-compatible root | 支持 | 支持 | passthrough OpenAI schema,不做 native Gemini 转换 |
|
| `openai:chat` | `openai:chat` on Google OpenAI-compatible root | 支持 | 支持 | passthrough OpenAI schema,不做 native Gemini 转换 |
|
||||||
| `openai:embedding` | `openai:embedding` on Google OpenAI-compatible root | 支持 | 支持 | passthrough OpenAI schema,不做 native Gemini 转换 |
|
| `openai:embedding` | `openai:embedding` on Google OpenAI-compatible root | 支持 | 支持 | passthrough OpenAI schema,不做 native Gemini 转换 |
|
||||||
@@ -247,8 +260,8 @@ OpenAI-compatible URL 属于显式 passthrough root,不参与 native Gemini UR
|
|||||||
这张矩阵的关键点:
|
这张矩阵的关键点:
|
||||||
|
|
||||||
1. 格式层必须能双向理解 Gemini native embedding request/response 与 OpenAI embedding request/response。
|
1. 格式层必须能双向理解 Gemini native embedding request/response 与 OpenAI embedding request/response。
|
||||||
2. Vertex 不支持 batch endpoint 是 transport/execution 能力限制,不是格式转换器不能表达 batch。
|
2. Vertex text embedding 使用 Predict contract;多输入由 `instances[]` 表达,不构造不存在的 `:batchEmbedContents`。
|
||||||
3. 一旦 provider family 是 Vertex,批量请求不能借格式转换之名回退到 Developer API。
|
3. 一旦 provider family 是 Vertex,任何 embedding 请求都不能借格式转换之名回退到 Developer API。
|
||||||
4. 对 OpenAI embedding 单项数组,转换器必须生成 Gemini 单条 body,避免把“单条业务请求”误判成 Vertex batch。
|
4. 对 OpenAI embedding 单项数组,转换器必须生成 Gemini 单条 body,避免把“单条业务请求”误判成 Vertex batch。
|
||||||
5. Google OpenAI-compatible passthrough 与 OpenAI -> Gemini native conversion 是两条显式路径。管理员通过 provider endpoint format 选择路径,Aether 不得自动“择优”改路。
|
5. Google OpenAI-compatible passthrough 与 OpenAI -> Gemini native conversion 是两条显式路径。管理员通过 provider endpoint format 选择路径,Aether 不得自动“择优”改路。
|
||||||
|
|
||||||
@@ -265,14 +278,14 @@ Vertex provider 的固定模板必须包含:
|
|||||||
Runtime policy 必须表达:
|
Runtime policy 必须表达:
|
||||||
|
|
||||||
- Vertex 能本地消费 Gemini generate content。
|
- Vertex 能本地消费 Gemini generate content。
|
||||||
- Vertex 能本地消费 Gemini single embedding。
|
- Vertex 能本地消费 Gemini embedding,并在 transport 层生成 Predict URL/body。
|
||||||
- Vertex 不支持直接消费 Gemini batch embedding,除非未来实现 Aether fan-out execution。
|
- Vertex text embedding 的模型级输入数量限制由 Vertex 返回;Aether 不静默拆分、不静默降级到 Developer API。
|
||||||
- 全局模型名与 Vertex 实际 provider 模型名必须可以分离。例如客户端继续请求全局 `gemini-embedding-2-preview` 时,Vertex provider model 可以映射到官方可用的 `gemini-embedding-2`;调度、key allowed_models、URL builder 必须消费映射后的 provider model,不得拿全局 preview 名直打 Vertex。
|
- 全局模型名与 Vertex 实际 provider 模型名必须可以分离。例如客户端继续请求全局 `gemini-embedding-2-preview` 时,Vertex provider model 可以映射到官方可用的 `gemini-embedding-2`;调度、key allowed_models、URL builder 必须消费映射后的 provider model,不得拿全局 preview 名直打 Vertex。
|
||||||
|
|
||||||
调度与 conversion policy 必须表达:
|
调度与 conversion policy 必须表达:
|
||||||
|
|
||||||
- `openai:embedding -> gemini:embedding` 可以被 Vertex provider 接收,仅限单条或 execution 层能处理的形态。
|
- `openai:embedding -> gemini:embedding` 可以被 Vertex provider 接收,transport 层负责把 Gemini Developer API body 转成 Vertex Predict body。
|
||||||
- 对批量 input,不能只因为 provider endpoint 叫 `gemini:embedding` 就认为 Vertex 已经完整支持 batch。
|
- 对批量 input,不能生成 Vertex `:batchEmbedContents`,也不能回退到 `generativelanguage.googleapis.com`。
|
||||||
- `request_pair_direct_auth` 对 Vertex API key 必须返回 `key` query auth;service account auth 由 OAuth refresh path 处理,不能伪造成普通 bearer key。
|
- `request_pair_direct_auth` 对 Vertex API key 必须返回 `key` query auth;service account auth 由 OAuth refresh path 处理,不能伪造成普通 bearer key。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -291,10 +304,13 @@ Runtime policy 必须表达:
|
|||||||
- API key auth -> `aiplatform.googleapis.com/v1/publishers/google/models/...`
|
- API key auth -> `aiplatform.googleapis.com/v1/publishers/google/models/...`
|
||||||
- service account -> project/location path
|
- service account -> project/location path
|
||||||
4. Vertex embedding URL:
|
4. Vertex embedding URL:
|
||||||
- API key auth -> `...:embedContent?key=...`
|
- API key auth -> `...:predict?key=...`
|
||||||
- service account -> project/location `...:embedContent`
|
- service account -> project/location `...:predict`
|
||||||
5. Vertex batch embedding:
|
5. Vertex embedding body:
|
||||||
- body 含顶层 `requests[]` 时,URL builder 返回 unsupported / `None`
|
- 单条 `model/content` body -> `instances[]`,且移除顶层 `model`
|
||||||
|
- body 含顶层 `requests[]` 时 -> `instances[]`
|
||||||
|
- body 已经是 `instances[]` 时只清理重复 `model`
|
||||||
|
- 无法映射的 body 在调度/模型测试阶段显式失败
|
||||||
- 不得生成 `generativelanguage.googleapis.com`
|
- 不得生成 `generativelanguage.googleapis.com`
|
||||||
- 不得生成 `aiplatform.googleapis.com/...:batchEmbedContents`
|
- 不得生成 `aiplatform.googleapis.com/...:batchEmbedContents`
|
||||||
6. Provider template:
|
6. Provider template:
|
||||||
@@ -302,9 +318,9 @@ Runtime policy 必须表达:
|
|||||||
- provider embedding support 矩阵包含 Vertex -> Gemini embedding
|
- provider embedding support 矩阵包含 Vertex -> Gemini embedding
|
||||||
7. Conversion:
|
7. Conversion:
|
||||||
- OpenAI embedding 可以被转换到 Gemini embedding provider format
|
- OpenAI embedding 可以被转换到 Gemini embedding provider format
|
||||||
- Vertex single embedding transport 可通过支持检查
|
- Vertex embedding transport 可通过支持检查
|
||||||
- Vertex single embedding execution plan 的 URL 使用 mapped provider model,body 不含顶层 `model`
|
- Vertex embedding execution plan 的 URL 使用 mapped provider model,body 不含顶层 `model`
|
||||||
- Vertex batch embedding 不得通过 direct URL 构造检查
|
- Vertex embedding execution plan 的 body 使用 `instances[]` / `parameters`
|
||||||
8. Gateway test connection:
|
8. Gateway test connection:
|
||||||
- Gemini generate content 测试不能强制 `maxOutputTokens = 5`
|
- Gemini generate content 测试不能强制 `maxOutputTokens = 5`
|
||||||
- Google OpenAI-compatible `openai:chat` 测试不能强制 `max_tokens = 5`,否则 Gemini thinking 模型仍可能只返回 thought token / 空 visible content
|
- Google OpenAI-compatible `openai:chat` 测试不能强制 `max_tokens = 5`,否则 Gemini thinking 模型仍可能只返回 thought token / 空 visible content
|
||||||
@@ -318,7 +334,7 @@ Runtime policy 必须表达:
|
|||||||
- Vertex API key key formats 允许 `gemini:generate_content` 与 `gemini:embedding`
|
- Vertex API key key formats 允许 `gemini:generate_content` 与 `gemini:embedding`
|
||||||
- Vertex service account key formats 允许 `claude:messages`、`gemini:generate_content` 与 `gemini:embedding`
|
- Vertex service account key formats 允许 `claude:messages`、`gemini:generate_content` 与 `gemini:embedding`
|
||||||
|
|
||||||
测试断言必须检查具体 URL、具体 action、具体 unsupported 结果,不能只检查 `Some(url)` 或状态码。
|
测试断言必须检查具体 URL、具体 action、具体 body contract 和具体失败原因,不能只检查 `Some(url)` 或状态码。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -337,8 +353,8 @@ Runtime policy 必须表达:
|
|||||||
- Developer API embedding 单条可用
|
- Developer API embedding 单条可用
|
||||||
- Developer API embedding 批量可用
|
- Developer API embedding 批量可用
|
||||||
- Vertex generate content 返回 visible content 才算成功
|
- Vertex generate content 返回 visible content 才算成功
|
||||||
- Vertex single embedding 可用
|
- Vertex embedding 单输入可用
|
||||||
- Vertex batch embedding 显式 unsupported,不能伪成功
|
- Vertex embedding 多输入如果模型拒绝,必须暴露 Vertex 原始失败,不能降级成 Developer API 成功
|
||||||
4. 接入方地址核验:
|
4. 接入方地址核验:
|
||||||
- astrbot plugin ltm
|
- astrbot plugin ltm
|
||||||
- codex cli config
|
- codex cli config
|
||||||
@@ -356,7 +372,7 @@ http://aether-app:8084/v1
|
|||||||
|
|
||||||
## 明确不做的事
|
## 明确不做的事
|
||||||
|
|
||||||
1. 不把 Vertex batch embedding 写成隐藏循环。隐藏 fan-out 会改变成本、延迟、断路器行为和重试语义,必须另开设计。
|
1. 不把 Vertex embedding 多输入写成隐藏循环。隐藏 fan-out 会改变成本、延迟、断路器行为和重试语义,必须另开设计。
|
||||||
2. 不为了测试通过把 Vertex 请求降级到 Developer API。
|
2. 不为了测试通过把 Vertex 请求降级到 Developer API。
|
||||||
3. 不为了让 HTTP 200 看起来成功而接受空 candidate / MAX_TOKENS 无 visible content。
|
3. 不为了让 HTTP 200 看起来成功而接受空 candidate / MAX_TOKENS 无 visible content。
|
||||||
4. 不改前端视觉定制、字体、品牌名、landing page 设计。
|
4. 不改前端视觉定制、字体、品牌名、landing page 设计。
|
||||||
@@ -369,8 +385,8 @@ http://aether-app:8084/v1
|
|||||||
|
|
||||||
1. 固化 endpoint family 判定与 URL helper。
|
1. 固化 endpoint family 判定与 URL helper。
|
||||||
2. 为 Vertex `gemini:embedding` 补齐 provider template、runtime policy、conversion policy。
|
2. 为 Vertex `gemini:embedding` 补齐 provider template、runtime policy、conversion policy。
|
||||||
3. 让 request URL builder 对 Vertex single embedding 走 Vertex helper。
|
3. 让 request URL builder 对 Vertex embedding 走 Vertex Predict helper。
|
||||||
4. 让 request URL builder 对 Vertex batch embedding fail closed。
|
4. 让 transport body semantics 对 Vertex embedding 生成 `instances[]` / `parameters`,无法转换时 fail closed。
|
||||||
5. 移除测试连接中对 Gemini generate content 的过低 `maxOutputTokens` 硬编码,防止 Gemini 3 thinking 被预算挤空。
|
5. 移除测试连接中对 Gemini generate content 的过低 `maxOutputTokens` 硬编码,防止 Gemini 3 thinking 被预算挤空。
|
||||||
6. 移除公开 test-connection 对 OpenAI-compatible chat 的过低 `max_tokens` 硬编码,防止 Google OpenAI-compatible root 复发同类空输出。
|
6. 移除公开 test-connection 对 OpenAI-compatible chat 的过低 `max_tokens` 硬编码,防止 Google OpenAI-compatible root 复发同类空输出。
|
||||||
7. 跑 red/green 测试。
|
7. 跑 red/green 测试。
|
||||||
@@ -384,9 +400,8 @@ http://aether-app:8084/v1
|
|||||||
如果 7 天 embedding 重算必须在 Vertex 上高吞吐完成,建议后续单独实现 `VertexEmbeddingFanoutExecutor`:
|
如果 7 天 embedding 重算必须在 Vertex 上高吞吐完成,建议后续单独实现 `VertexEmbeddingFanoutExecutor`:
|
||||||
|
|
||||||
- 输入 OpenAI embedding 数组。
|
- 输入 OpenAI embedding 数组。
|
||||||
- 按配置分片,每片发单条或有限并发 Vertex `embedContent`。
|
- 按配置分片,每片发单条或有限并发 Vertex `predict`。
|
||||||
- 合并为 OpenAI embedding response。
|
- 合并为 OpenAI embedding response。
|
||||||
- 将每个子请求的失败、重试、成本、断路器状态独立记录。
|
- 将每个子请求的失败、重试、成本、断路器状态独立记录。
|
||||||
- UI 上明确显示这是 Aether fan-out,不是 Google 官方 Vertex batch endpoint。
|
|
||||||
|
|
||||||
这项增强不能混入本次 endpoint 语义修复,否则会扩大风险面。
|
这项增强不能混入本次 endpoint 语义修复,否则会扩大风险面。
|
||||||
|
|||||||
Reference in New Issue
Block a user