fix(gateway): normalize Gemini Vertex embedding transport

This commit is contained in:
MMEXA
2026-05-18 15:39:29 +00:00
parent 66f154a251
commit b480f3aaff
24 changed files with 1046 additions and 107 deletions

View File

@@ -241,11 +241,29 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
upstream_is_stream,
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,
transport,
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) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
@@ -266,11 +284,29 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
upstream_is_stream,
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,
transport,
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() {
@@ -382,22 +418,12 @@ fn apply_transport_request_body_semantics(
provider_request_body: &mut Value,
transport: &GatewayProviderTransportSnapshot,
provider_api_format: &str,
) {
if !crate::ai_serving::api_format_alias_matches(provider_api_format, "gemini:embedding")
|| !crate::ai_serving::transport::vertex::is_vertex_transport_context(transport)
{
return;
}
let Some(object) = provider_request_body.as_object_mut() else {
return;
};
if object.contains_key("requests") {
return;
}
object.remove("model");
) -> Result<(), crate::ai_serving::transport::TransportRequestBodySemanticsError> {
crate::ai_serving::transport::apply_transport_request_body_semantics(
provider_request_body,
transport,
provider_api_format,
)
}
async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(

View File

@@ -51,7 +51,8 @@ pub(crate) use aether_provider_transport::{
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_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_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,
@@ -89,5 +90,6 @@ pub(crate) use aether_provider_transport::{
SameFormatProviderRequestBehaviorParams, SameFormatProviderRequestBodyInput,
SameFormatProviderUpstreamUrlParams, StandardPlanFallbackAcceptPolicy,
StandardPlanFallbackHeadersInput, StandardProviderRequestHeaders,
StandardProviderRequestHeadersInput, TransportRequestUrlParams,
StandardProviderRequestHeadersInput, TransportRequestBodySemanticsError,
TransportRequestUrlParams,
};

View File

@@ -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> {
match crate::ai_serving::normalize_api_format_alias(api_format).as_str() {
"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:files" => Some("/v1beta/files"),
_ => None,

View File

@@ -86,7 +86,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:embedding", "gemini", "embedding", "/v1/embeddings"),
(
"gemini:embedding",
"gemini",
"embedding",
"/v1beta/models/{model}:{action}",
),
("jina:embedding", "jina", "embedding", "/v1/embeddings"),
("doubao:embedding", "doubao", "embedding", "/v1/embeddings"),
("openai:rerank", "openai", "rerank", "/v1/rerank"),

View File

@@ -2,7 +2,8 @@ use aether_contracts::{ExecutionPlan, ExecutionResult};
use serde_json::Value;
use crate::orchestration::{
resolve_local_failover_analysis_for_attempt, LocalFailoverAnalysis, LocalFailoverDecision,
resolve_local_failover_analysis_for_attempt, LocalFailoverAnalysis,
LocalFailoverClassification, LocalFailoverDecision,
};
use crate::AppState;
@@ -48,6 +49,15 @@ pub(crate) async fn analyze_local_candidate_failover_sync(
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(
state,
plan,
@@ -326,14 +336,14 @@ pub(crate) fn resolve_core_stream_direct_finalize_report_kind(plan_kind: &str) -
mod tests {
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_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
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,
should_fallback_to_control_sync, should_retry_next_local_candidate_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]
async fn sync_retry_next_candidate_skips_video_follow_up_plan_kinds() {
let result = ExecutionResult {

View File

@@ -2304,7 +2304,7 @@ async fn provider_query_execute_standard_test_candidate(
}
"openai:embedding" | "gemini:embedding" | "jina:embedding" | "doubao:embedding"
| "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(
&request_body,
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}"),
));
};
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
}
_ => {
@@ -2404,7 +2416,7 @@ async fn provider_query_execute_standard_test_candidate(
*synthetic_request.headers_mut() = incoming_request_headers;
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,
crate::provider_transport::TransportRequestUrlParams {
provider_api_format,
@@ -2413,6 +2425,7 @@ async fn provider_query_execute_standard_test_candidate(
request_query: parts.uri.query(),
kiro_api_region: None,
},
Some(&provider_request_body),
);
let Some(request_url) = request_url else {
return Ok(provider_query_skipped_execution_outcome(

View File

@@ -62,10 +62,10 @@ pub(super) fn provider_query_standard_test_unsupported_reason(
api_format,
)
}
"gemini:generate_content"
if crate::provider_transport::is_vertex_api_key_transport_context(transport) =>
"gemini:generate_content" | "gemini:embedding"
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,
)
}

View File

@@ -7,11 +7,29 @@ pub(super) fn provider_query_test_attempt_payload(
candidate: &ProviderQueryTestCandidate,
execution: &ProviderQueryExecutionOutcome,
) -> 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!({
"candidate_index": candidate_index,
"retry_index": 0,
"endpoint_api_format": candidate.endpoint.api_format,
"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_id": candidate.key.id,
"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(
total_candidates: usize,
total_attempts: usize,

View File

@@ -32,6 +32,7 @@ pub(crate) enum LocalFailoverClassification {
UseDefault,
StopStatusCode,
StopErrorPattern,
StopExecutionError,
RetrySuccessPattern,
RetryStatusCode,
RetryUpstreamFailure,
@@ -43,6 +44,7 @@ impl LocalFailoverClassification {
Self::UseDefault => "use_default",
Self::StopStatusCode => "stop_status_code",
Self::StopErrorPattern => "stop_error_pattern",
Self::StopExecutionError => "stop_execution_error",
Self::RetrySuccessPattern => "retry_success_pattern",
Self::RetryStatusCode => "retry_status_code",
Self::RetryUpstreamFailure => "retry_upstream_failure",

View File

@@ -821,7 +821,8 @@ fn local_candidate_failure_should_invalidate_affinity(
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
status_code >= 500
}
LocalFailoverClassification::StopErrorPattern => false,
LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError => false,
}
}

View File

@@ -155,7 +155,8 @@ fn local_candidate_failure_should_project_health(
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
status_code >= 500
}
LocalFailoverClassification::StopErrorPattern => false,
LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError => false,
}
}

View File

@@ -92,7 +92,9 @@ pub(crate) fn build_local_error_flow_metadata(
) -> Value {
let safe_to_expose = matches!(
analysis.classification,
LocalFailoverClassification::StopStatusCode | LocalFailoverClassification::StopErrorPattern
LocalFailoverClassification::StopStatusCode
| LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError
);
let propagation = match analysis.decision {
LocalFailoverDecision::RetryNextCandidate => "suppressed",

View File

@@ -57,7 +57,10 @@ const fn decision_from_classification(
match classification {
LocalFailoverClassification::UseDefault => LocalFailoverDecision::UseDefault,
LocalFailoverClassification::StopStatusCode
| LocalFailoverClassification::StopErrorPattern => LocalFailoverDecision::StopLocalFailover,
| LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError => {
LocalFailoverDecision::StopLocalFailover
}
LocalFailoverClassification::RetrySuccessPattern
| LocalFailoverClassification::RetryStatusCode
| LocalFailoverClassification::RetryUpstreamFailure => {

View File

@@ -1256,6 +1256,146 @@ async fn gateway_handles_admin_provider_query_gemini_embedding_model_test() {
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]
async fn gateway_handles_admin_provider_query_jina_embedding_model_test() {
let execution_runtime = Router::new().route(

View File

@@ -1152,6 +1152,14 @@ async fn gateway_handles_admin_system_api_formats_locally_with_trusted_admin_pri
.expect("formats should be an array");
assert_eq!(formats[0]["value"], "openai:chat");
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
.iter()
.any(|item| item["value"] == "openai:embedding"));

View File

@@ -194,7 +194,7 @@ fn vertex_gemini_embedding_conversion_execution_runtime() -> Router {
"/v1/execute/sync",
any(|Json(plan): Json<ExecutionPlan>| async move {
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.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!(
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");
assert!(
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("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 {
ExecutionResult {
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"));
let payload: serde_json::Value = serde_json::from_str(&body_text).expect("body should parse");
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]));
gateway_handle.abort();

View File

@@ -736,7 +736,7 @@ const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
AdminApiFormatDefinition {
value: "gemini:embedding",
label: "Gemini Embedding",
default_path: "/v1/embeddings",
default_path: "/v1beta/models/{model}:{action}",
aliases: &["gemini_embedding"],
},
AdminApiFormatDefinition {

View File

@@ -11,7 +11,9 @@ pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
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")
.and_then(Value::as_object)
.and_then(|embedding| embedding.get("values"))
@@ -56,6 +58,7 @@ pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
model: body
.get("model")
.or_else(|| body.get("modelVersion"))
.or_else(|| body.get("deployedModelId"))
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
@@ -69,14 +72,36 @@ pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
"responseId",
"model",
"modelVersion",
"deployedModelId",
"embedding",
"embeddings",
"predictions",
"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>> {
values.iter().map(Value::as_f64).collect()
}
@@ -105,4 +130,29 @@ mod tests {
assert_eq!(usage.input_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]);
}
}

View File

@@ -162,6 +162,21 @@ impl CandidateFailureDiagnostic {
.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(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,

View File

@@ -14,6 +14,7 @@ pub mod oauth_refresh;
mod openai_image;
pub mod policy;
pub mod provider_types;
mod request_body;
mod request_url;
pub mod rules;
pub mod same_format_provider;
@@ -69,6 +70,9 @@ pub use policy::{
local_standard_transport_unsupported_reason_with_network, supports_local_gemini_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::{
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,

View 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());
}
}

View File

@@ -19,8 +19,8 @@ use crate::url::{
use crate::vertex::{
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_embedding_url, is_vertex_transport_context,
resolve_local_vertex_api_key_query_auth, resolve_local_vertex_service_account_auth_config,
build_vertex_service_account_gemini_embedding_url, resolve_local_vertex_api_key_query_auth,
resolve_local_vertex_service_account_auth_config,
};
#[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 normalized_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) {
return Some(url);
}
@@ -696,12 +689,12 @@ mod tests {
assert_eq!(
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]
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(
"vertex_ai",
"gemini:embedding",
@@ -729,18 +722,23 @@ mod tests {
]
});
assert!(build_transport_request_url_for_request_body(
&transport,
TransportRequestUrlParams {
provider_api_format: "gemini:embedding",
mapped_model: Some("gemini-embedding-2"),
upstream_is_stream: false,
request_query: None,
kiro_api_region: None,
},
Some(&batch_body),
)
.is_none());
assert_eq!(
build_transport_request_url_for_request_body(
&transport,
TransportRequestUrlParams {
provider_api_format: "gemini:embedding",
mapped_model: Some("gemini-embedding-2"),
upstream_is_stream: false,
request_query: None,
kiro_api_region: None,
},
Some(&batch_body),
)
.as_deref(),
Some(
"https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/publishers/google/models/gemini-embedding-2:predict"
)
);
}
#[test]

View File

@@ -40,7 +40,7 @@ pub fn build_vertex_api_key_gemini_embedding_url(
api_key: &str,
request_query: Option<&str>,
) -> 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(
@@ -64,7 +64,7 @@ pub fn build_vertex_service_account_gemini_embedding_url(
) -> Option<String> {
build_vertex_service_account_google_model_url(
model,
"embedContent",
"predict",
false,
auth_config,
request_query,

View File

@@ -28,14 +28,14 @@ Aether 还必须区分 Google 官方的 OpenAI-compatible 表面。它们使用
| --- | --- | --- | --- |
| 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 构造不同 |
| Single Embedding | `models/{model}:embedContent` | `projects/{project}/locations/{location}/publishers/google/models/{model}:embedContent` | 两边都支持,但 URL 构造不同 |
| Batch Embedding | `models/{model}:batchEmbedContents` | 官方 REST reference 当前未提供同名 Vertex 方法 | Developer API 可批量Vertex 必须显式拒绝或拆分,不得伪装成 Vertex batch |
| 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` | 同一个 `:predict`,由 `instances[]` 表达多输入 | Aether 不切到 Developer API模型自身的批量限制由 Vertex 明确返回 |
工程不变量:
1. 默认 Gemini provider 只能生成 Gemini Developer API URL不得因为模型名是 Gemini 就走 Vertex。
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 构造阶段显式暴露为不可用,不能伪成功。
5. Provider 模板、runtime policy、URL builder、conversion policy、测试连接、live DB reconciliation 必须消费同一个语义模型。
6. Google 官方 OpenAI-compatible root 已经包含 API rootAether 不得额外拼接 `/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 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 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 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>
@@ -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/...`
- Vertex API 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 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` | 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 | true | unsupported, fail closed | 官方 REST reference 未提供 Vertex batch method |
| `gemini:embedding` | false | false | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:predict` |
| `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不让它伪成功。
2. 第二阶段显式 fan-outAether 自己把数组拆成多条 Vertex `embedContent` 调用,再按 OpenAI embedding 响应格式合并。
```json
{
"instances": [
{ "content": "text", "task_type": "RETRIEVAL_QUERY", "title": "optional" }
],
"parameters": {
"outputDimensionality": 768,
"autoTruncate": true
}
}
```
本次先做第一阶段,因为它不会隐藏批量语义差异;后续如果实现 fan-out必须有单独设计与负载控制不得把 fan-out 塞进 URL builder
如果输入已经是 Predict bodyAether 只移除重复的顶层 `model`。如果输入仍是 OpenAI body 或无法确定可转换,调度阶段必须显式失败,不得把未转换 body 发到 Vertex native endpoint
### Google OpenAI-Compatible URL
@@ -215,10 +227,11 @@ OpenAI-compatible URL 属于显式 passthrough root不参与 native Gemini UR
设计要求:
1. 该 converter 可以继续生成 Gemini batch body但 transport 层必须知道这对 Vertex 不可直接消费
2. 如果未来实现 Vertex fan-outfan-out 应发生在 gateway execution 层,而不是让 `request_url` 或 body converter 假装一个 Vertex batch endpoint 存在
1. 该 converter 可以继续生成 Gemini Developer API 的 `embedContent` / `batchEmbedContents` body
2. Transport 层必须在 Vertex context 下把该 body 转成 Predict body并在无法转换时 fail closed
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: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 | 处理要求 |
| --- | --- | --- | --- | --- |
| `openai:chat` | `gemini:generate_content` | 支持 | 支持 | OpenAI chat -> Gemini contents / generationConfig |
| `gemini:generate_content` | `openai:chat` | 支持 | 支持 | Gemini contents -> OpenAI messages |
| `openai:embedding` | `gemini:embedding` 单条 | 支持 | 支持 | OpenAI input string 或单项数组 -> Gemini `embedContent` body |
| `openai:embedding` | `gemini:embedding` 多条 | 支持 | fail closed | Developer API -> `batchEmbedContents`; Vertex 无官方 batch endpoint |
| `openai:embedding` | `gemini:embedding` 单条 | 支持 | 支持 | OpenAI input string 或单项数组 -> Gemini `embedContent` bodyVertex transport 再转 `instances[]` |
| `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` | 支持 | 支持于格式层;执行层仍受 Vertex batch 限制 | Gemini `requests[]` -> OpenAI `input[]` |
| `gemini:embedding` response | `openai:embedding` response | 支持 | 支持 | Gemini `embedding.values` / `embeddings[].values` -> OpenAI `data[].embedding` |
| `gemini:embedding` 批量 | `openai:embedding` | 支持 | 支持 | Gemini `requests[]` -> OpenAI `input[]`Vertex Predict response 同样可转 |
| `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: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 转换 |
@@ -247,8 +260,8 @@ OpenAI-compatible URL 属于显式 passthrough root不参与 native Gemini UR
这张矩阵的关键点:
1. 格式层必须能双向理解 Gemini native embedding request/response 与 OpenAI embedding request/response。
2. Vertex 不支持 batch endpoint 是 transport/execution 能力限制,不是格式转换器不能表达 batch
3. 一旦 provider family 是 Vertex批量请求不能借格式转换之名回退到 Developer API。
2. Vertex text embedding 使用 Predict contract多输入由 `instances[]` 表达,不构造不存在的 `:batchEmbedContents`
3. 一旦 provider family 是 Vertex任何 embedding 请求不能借格式转换之名回退到 Developer API。
4. 对 OpenAI embedding 单项数组,转换器必须生成 Gemini 单条 body避免把“单条业务请求”误判成 Vertex batch。
5. Google OpenAI-compatible passthrough 与 OpenAI -> Gemini native conversion 是两条显式路径。管理员通过 provider endpoint format 选择路径Aether 不得自动“择优”改路。
@@ -265,14 +278,14 @@ Vertex provider 的固定模板必须包含:
Runtime policy 必须表达:
- Vertex 能本地消费 Gemini generate content。
- Vertex 能本地消费 Gemini single embedding。
- Vertex 不支持直接消费 Gemini batch embedding除非未来实现 Aether fan-out execution
- Vertex 能本地消费 Gemini embedding,并在 transport 层生成 Predict URL/body
- 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。
调度与 conversion policy 必须表达:
- `openai:embedding -> gemini:embedding` 可以被 Vertex provider 接收,仅限单条或 execution 层能处理的形态
- 对批量 input不能只因为 provider endpoint 叫 `gemini:embedding` 就认为 Vertex 已经完整支持 batch
- `openai:embedding -> gemini:embedding` 可以被 Vertex provider 接收,transport 层负责把 Gemini Developer API body 转成 Vertex Predict body
- 对批量 input不能生成 Vertex `:batchEmbedContents`,也不能回退到 `generativelanguage.googleapis.com`
- `request_pair_direct_auth` 对 Vertex API key 必须返回 `key` query authservice account auth 由 OAuth refresh path 处理,不能伪造成普通 bearer key。
---
@@ -291,10 +304,13 @@ Runtime policy 必须表达:
- API key auth -> `aiplatform.googleapis.com/v1/publishers/google/models/...`
- service account -> project/location path
4. Vertex embedding URL:
- API key auth -> `...:embedContent?key=...`
- service account -> project/location `...:embedContent`
5. Vertex batch embedding:
- body 含顶层 `requests[]`URL builder 返回 unsupported / `None`
- API key auth -> `...:predict?key=...`
- service account -> project/location `...:predict`
5. Vertex embedding body:
- 单条 `model/content` body -> `instances[]`,且移除顶层 `model`
- body 含顶层 `requests[]` 时 -> `instances[]`
- body 已经是 `instances[]` 时只清理重复 `model`
- 无法映射的 body 在调度/模型测试阶段显式失败
- 不得生成 `generativelanguage.googleapis.com`
- 不得生成 `aiplatform.googleapis.com/...:batchEmbedContents`
6. Provider template:
@@ -302,9 +318,9 @@ Runtime policy 必须表达:
- provider embedding support 矩阵包含 Vertex -> Gemini embedding
7. Conversion:
- OpenAI embedding 可以被转换到 Gemini embedding provider format
- Vertex single embedding transport 可通过支持检查
- Vertex single embedding execution plan 的 URL 使用 mapped provider modelbody 不含顶层 `model`
- Vertex batch embedding 不得通过 direct URL 构造检查
- Vertex embedding transport 可通过支持检查
- Vertex embedding execution plan 的 URL 使用 mapped provider modelbody 不含顶层 `model`
- Vertex embedding execution plan 的 body 使用 `instances[]` / `parameters`
8. Gateway test connection:
- Gemini generate content 测试不能强制 `maxOutputTokens = 5`
- 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 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 批量可用
- Vertex generate content 返回 visible content 才算成功
- Vertex single embedding 可用
- Vertex batch embedding 显式 unsupported不能伪成功
- Vertex embedding 单输入可用
- Vertex embedding 多输入如果模型拒绝,必须暴露 Vertex 原始失败,不能降级成 Developer API 成功
4. 接入方地址核验:
- astrbot plugin ltm
- 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。
3. 不为了让 HTTP 200 看起来成功而接受空 candidate / MAX_TOKENS 无 visible content。
4. 不改前端视觉定制、字体、品牌名、landing page 设计。
@@ -369,8 +385,8 @@ http://aether-app:8084/v1
1. 固化 endpoint family 判定与 URL helper。
2. 为 Vertex `gemini:embedding` 补齐 provider template、runtime policy、conversion policy。
3. 让 request URL builder 对 Vertex single embedding 走 Vertex helper。
4.request URL builder 对 Vertex batch embedding fail closed。
3. 让 request URL builder 对 Vertex embedding 走 Vertex Predict helper。
4.transport body semantics 对 Vertex embedding 生成 `instances[]` / `parameters`,无法转换时 fail closed。
5. 移除测试连接中对 Gemini generate content 的过低 `maxOutputTokens` 硬编码,防止 Gemini 3 thinking 被预算挤空。
6. 移除公开 test-connection 对 OpenAI-compatible chat 的过低 `max_tokens` 硬编码,防止 Google OpenAI-compatible root 复发同类空输出。
7. 跑 red/green 测试。
@@ -384,9 +400,8 @@ http://aether-app:8084/v1
如果 7 天 embedding 重算必须在 Vertex 上高吞吐完成,建议后续单独实现 `VertexEmbeddingFanoutExecutor`
- 输入 OpenAI embedding 数组。
- 按配置分片,每片发单条或有限并发 Vertex `embedContent`
- 按配置分片,每片发单条或有限并发 Vertex `predict`
- 合并为 OpenAI embedding response。
- 将每个子请求的失败、重试、成本、断路器状态独立记录。
- UI 上明确显示这是 Aether fan-out不是 Google 官方 Vertex batch endpoint。
这项增强不能混入本次 endpoint 语义修复,否则会扩大风险面。