mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(gateway): harden Gemini endpoint routing
This commit is contained in:
@@ -241,6 +241,11 @@ 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(
|
||||||
|
&mut provider_request_body,
|
||||||
|
transport,
|
||||||
|
provider_api_format,
|
||||||
|
);
|
||||||
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,
|
||||||
@@ -261,6 +266,11 @@ 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(
|
||||||
|
&mut provider_request_body,
|
||||||
|
transport,
|
||||||
|
provider_api_format,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||||
@@ -368,6 +378,28 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::control::GatewayControlDecision;
|
|||||||
use crate::usage::spawn_sync_report;
|
use crate::usage::spawn_sync_report;
|
||||||
use crate::{usage::GatewaySyncReportRequest, AppState, GatewayError};
|
use crate::{usage::GatewaySyncReportRequest, AppState, GatewayError};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::Response;
|
use axum::http::{Response, StatusCode};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
@@ -148,6 +148,74 @@ fn build_local_core_sync_finalize_fallback_response(
|
|||||||
build_local_sync_response_from_bytes(trace_id, decision, payload, Vec::new())
|
build_local_sync_response_from_bytes(trace_id, decision, payload, Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn maybe_build_invalid_provider_success_finalize_response(
|
||||||
|
trace_id: &str,
|
||||||
|
decision: &GatewayControlDecision,
|
||||||
|
payload: &GatewaySyncReportRequest,
|
||||||
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
|
if !local_core_sync_finalize_has_invalid_provider_success(payload)? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_api_format = resolve_local_sync_client_api_format(payload);
|
||||||
|
let message = "Provider returned HTTP 200 but the Gemini response did not contain visible model output; refusing to finalize it as a successful response.";
|
||||||
|
let body_json = build_core_error_body_for_client_format(
|
||||||
|
&client_api_format,
|
||||||
|
message,
|
||||||
|
Some("invalid_provider_success_response"),
|
||||||
|
LocalCoreSyncErrorKind::ServerError,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"message": message,
|
||||||
|
"type": "server_error",
|
||||||
|
"code": "invalid_provider_success_response"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut response_headers = payload.headers.clone();
|
||||||
|
response_headers.remove("content-encoding");
|
||||||
|
response_headers.remove("content-length");
|
||||||
|
response_headers.insert("content-type".to_string(), "application/json".to_string());
|
||||||
|
let body_bytes =
|
||||||
|
serde_json::to_vec(&body_json).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
|
response_headers.insert("content-length".to_string(), body_bytes.len().to_string());
|
||||||
|
|
||||||
|
Ok(Some(build_client_response_from_parts(
|
||||||
|
StatusCode::BAD_GATEWAY.as_u16(),
|
||||||
|
&response_headers,
|
||||||
|
Body::from(body_bytes),
|
||||||
|
trace_id,
|
||||||
|
Some(decision),
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_core_sync_finalize_has_invalid_provider_success(
|
||||||
|
payload: &GatewaySyncReportRequest,
|
||||||
|
) -> Result<bool, GatewayError> {
|
||||||
|
if payload.status_code >= 400 || !is_core_error_finalize_kind(payload.report_kind.as_str()) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let provider_api_format = resolve_local_sync_provider_api_format(payload);
|
||||||
|
if aether_ai_formats::normalize_api_format_alias(&provider_api_format)
|
||||||
|
!= "gemini:generate_content"
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let Some(body_json) = resolve_local_sync_source_body_json(payload)? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if has_nested_error(&body_json) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
aether_ai_formats::formats::gemini::generate_content::response::from_raw(&body_json)
|
||||||
|
.is_none(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_best_effort_local_core_error_body(
|
pub(crate) fn build_best_effort_local_core_error_body(
|
||||||
payload: &GatewaySyncReportRequest,
|
payload: &GatewaySyncReportRequest,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
@@ -283,6 +351,16 @@ fn resolve_local_sync_client_api_format(payload: &GatewaySyncReportRequest) -> S
|
|||||||
.to_ascii_lowercase()
|
.to_ascii_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_local_sync_provider_api_format(payload: &GatewaySyncReportRequest) -> String {
|
||||||
|
payload
|
||||||
|
.report_context
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("provider_api_format"))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
.unwrap_or_else(|| resolve_local_sync_client_api_format(payload))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_core_error_background_report_kind(report_kind: &str) -> Option<String> {
|
pub(crate) fn resolve_core_error_background_report_kind(report_kind: &str) -> Option<String> {
|
||||||
core_error_background_report_kind(report_kind).map(ToOwned::to_owned)
|
core_error_background_report_kind(report_kind).map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
@@ -522,6 +600,10 @@ pub(crate) async fn submit_local_core_error_or_sync_finalize(
|
|||||||
maybe_compile_sync_finalize_response(trace_id, decision, &payload)?
|
maybe_compile_sync_finalize_response(trace_id, decision, &payload)?
|
||||||
{
|
{
|
||||||
response
|
response
|
||||||
|
} else if let Some(response) =
|
||||||
|
maybe_build_invalid_provider_success_finalize_response(trace_id, decision, &payload)?
|
||||||
|
{
|
||||||
|
response
|
||||||
} else if let Some(response) =
|
} else if let Some(response) =
|
||||||
maybe_build_local_core_error_response(trace_id, decision, &payload)?
|
maybe_build_local_core_error_response(trace_id, decision, &payload)?
|
||||||
{
|
{
|
||||||
@@ -566,9 +648,10 @@ mod tests {
|
|||||||
use axum::body::to_bytes;
|
use axum::body::to_bytes;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::maybe_build_local_core_error_response;
|
use super::{maybe_build_local_core_error_response, submit_local_core_error_or_sync_finalize};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::usage::GatewaySyncReportRequest;
|
use crate::usage::GatewaySyncReportRequest;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
fn test_decision() -> GatewayControlDecision {
|
fn test_decision() -> GatewayControlDecision {
|
||||||
GatewayControlDecision::synthetic(
|
GatewayControlDecision::synthetic(
|
||||||
@@ -684,4 +767,59 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_core_sync_finalize_rejects_gemini_http_200_without_visible_output() {
|
||||||
|
let mut payload = core_finalize_payload(
|
||||||
|
"openai_chat_sync_finalize",
|
||||||
|
"openai:chat",
|
||||||
|
"gemini:generate_content",
|
||||||
|
200,
|
||||||
|
json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {"role": "model"},
|
||||||
|
"finishReason": "MAX_TOKENS"
|
||||||
|
}],
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 8,
|
||||||
|
"candidatesTokenCount": 1,
|
||||||
|
"thoughtsTokenCount": 25,
|
||||||
|
"totalTokenCount": 34
|
||||||
|
},
|
||||||
|
"modelVersion": "gemini-3-flash-preview",
|
||||||
|
"responseId": "resp-empty"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
payload.report_context = Some(json!({
|
||||||
|
"client_api_format": "openai:chat",
|
||||||
|
"provider_api_format": "gemini:generate_content",
|
||||||
|
"needs_conversion": true,
|
||||||
|
"has_envelope": false
|
||||||
|
}));
|
||||||
|
|
||||||
|
let state = AppState::new().expect("state should build");
|
||||||
|
let response = submit_local_core_error_or_sync_finalize(
|
||||||
|
&state,
|
||||||
|
"trace-invalid-gemini-200",
|
||||||
|
&test_decision(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response should build");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), http::StatusCode::BAD_GATEWAY);
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("body should decode");
|
||||||
|
let message = body["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.expect("error message should exist");
|
||||||
|
assert!(
|
||||||
|
message.contains("visible model output"),
|
||||||
|
"unexpected message: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use std::io::Error as IoError;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use aether_contracts::{ExecutionPlan, ExecutionResult, ExecutionTelemetry};
|
use aether_contracts::{
|
||||||
|
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionPlan, ExecutionResult,
|
||||||
|
ExecutionTelemetry,
|
||||||
|
};
|
||||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||||
use aether_scheduler_core::{
|
use aether_scheduler_core::{
|
||||||
execution_error_details, parse_request_candidate_report_context,
|
execution_error_details, parse_request_candidate_report_context,
|
||||||
@@ -26,8 +29,8 @@ use tokio::time::MissedTickBehavior;
|
|||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::ai_serving::api::{
|
use crate::ai_serving::api::{
|
||||||
implicit_sync_finalize_report_kind, maybe_build_sync_finalize_outcome,
|
build_core_error_body_for_client_format, implicit_sync_finalize_report_kind,
|
||||||
LocalCoreSyncFinalizeOutcome,
|
maybe_build_sync_finalize_outcome, LocalCoreSyncErrorKind, LocalCoreSyncFinalizeOutcome,
|
||||||
};
|
};
|
||||||
use crate::api::response::{
|
use crate::api::response::{
|
||||||
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
|
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
|
||||||
@@ -183,6 +186,55 @@ fn build_sync_report_payload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn invalid_gemini_provider_success_message(
|
||||||
|
plan: &ExecutionPlan,
|
||||||
|
report_context: Option<&Value>,
|
||||||
|
status_code: u16,
|
||||||
|
body_json: Option<&Value>,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
if status_code >= 400 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let provider_api_format = report_context
|
||||||
|
.and_then(|value| value.get("provider_api_format"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(plan.provider_api_format.as_str());
|
||||||
|
if aether_ai_formats::normalize_api_format_alias(provider_api_format)
|
||||||
|
!= "gemini:generate_content"
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body_json = body_json?;
|
||||||
|
if body_json
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|object| object.get("error").is_some_and(|error| !error.is_null()))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if aether_ai_formats::formats::gemini::generate_content::response::from_raw(body_json).is_some()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some("Provider returned HTTP 200 but the Gemini response did not contain visible model output; refusing to finalize it as a successful response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_invalid_provider_success_body(
|
||||||
|
plan: &ExecutionPlan,
|
||||||
|
report_context: Option<&Value>,
|
||||||
|
message: &str,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let client_api_format = report_context
|
||||||
|
.and_then(|value| value.get("client_api_format"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(plan.client_api_format.as_str());
|
||||||
|
build_core_error_body_for_client_format(
|
||||||
|
client_api_format,
|
||||||
|
message,
|
||||||
|
Some("invalid_provider_success_response"),
|
||||||
|
LocalCoreSyncErrorKind::ServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct OpenAiImageSyncProgressSnapshot {
|
struct OpenAiImageSyncProgressSnapshot {
|
||||||
phase: &'static str,
|
phase: &'static str,
|
||||||
@@ -1337,19 +1389,37 @@ async fn execute_execution_runtime_sync_impl(
|
|||||||
local_failover_response_text,
|
local_failover_response_text,
|
||||||
local_failover_analysis,
|
local_failover_analysis,
|
||||||
) = loop {
|
) = loop {
|
||||||
let result_body_json = result
|
|
||||||
.body
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|body| body.json_body.as_ref());
|
|
||||||
let (result_error_type, result_error_message) =
|
|
||||||
execution_error_details(result.error.as_ref(), result_body_json);
|
|
||||||
let result_latency_ms = result
|
let result_latency_ms = result
|
||||||
.telemetry
|
.telemetry
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|telemetry| telemetry.elapsed_ms);
|
.and_then(|telemetry| telemetry.elapsed_ms);
|
||||||
let mut headers = std::mem::take(&mut result.headers);
|
let mut headers = std::mem::take(&mut result.headers);
|
||||||
let (body_bytes, body_json, body_base64) =
|
let (body_bytes, mut body_json, body_base64) =
|
||||||
decode_execution_result_body(result.body.take(), &mut headers)?;
|
decode_execution_result_body(result.body.take(), &mut headers)?;
|
||||||
|
if let Some(message) = invalid_gemini_provider_success_message(
|
||||||
|
&plan,
|
||||||
|
report_context.as_ref(),
|
||||||
|
result.status_code,
|
||||||
|
body_json.as_ref(),
|
||||||
|
) {
|
||||||
|
result.status_code = StatusCode::BAD_GATEWAY.as_u16();
|
||||||
|
result.error = Some(ExecutionError {
|
||||||
|
kind: ExecutionErrorKind::Upstream5xx,
|
||||||
|
phase: ExecutionPhase::Finalize,
|
||||||
|
message: message.to_string(),
|
||||||
|
upstream_status: Some(StatusCode::OK.as_u16()),
|
||||||
|
retryable: false,
|
||||||
|
failover_recommended: false,
|
||||||
|
});
|
||||||
|
if let Some(error_body) =
|
||||||
|
build_invalid_provider_success_body(&plan, report_context.as_ref(), message)
|
||||||
|
{
|
||||||
|
body_json = Some(error_body);
|
||||||
|
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (result_error_type, result_error_message) =
|
||||||
|
execution_error_details(result.error.as_ref(), body_json.as_ref());
|
||||||
let local_failover_response_text = local_failover_response_text(
|
let local_failover_response_text = local_failover_response_text(
|
||||||
body_json.as_ref(),
|
body_json.as_ref(),
|
||||||
&body_bytes,
|
&body_bytes,
|
||||||
@@ -2058,6 +2128,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_gemini_chat_plan() -> ExecutionPlan {
|
||||||
|
let mut plan = test_openai_image_plan(false);
|
||||||
|
plan.client_api_format = "openai:chat".to_string();
|
||||||
|
plan.provider_api_format = "gemini:generate_content".to_string();
|
||||||
|
plan.model_name = Some("gemini-3-flash-preview".to_string());
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_gemini_provider_success_uses_plan_format_when_context_is_missing() {
|
||||||
|
let plan = test_gemini_chat_plan();
|
||||||
|
let body = json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {"role": "model"},
|
||||||
|
"finishReason": "MAX_TOKENS"
|
||||||
|
}],
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 8,
|
||||||
|
"candidatesTokenCount": 1,
|
||||||
|
"thoughtsTokenCount": 25,
|
||||||
|
"totalTokenCount": 34
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let message = invalid_gemini_provider_success_message(
|
||||||
|
&plan,
|
||||||
|
None,
|
||||||
|
StatusCode::OK.as_u16(),
|
||||||
|
Some(&body),
|
||||||
|
)
|
||||||
|
.expect("empty Gemini 200 response should be rejected from plan format");
|
||||||
|
|
||||||
|
assert!(message.contains("visible model output"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn json_whitespace_heartbeat_stream_prefixes_final_json() {
|
async fn json_whitespace_heartbeat_stream_prefixes_final_json() {
|
||||||
let (tx, rx) = mpsc::channel::<Result<Bytes, IoError>>(1);
|
let (tx, rx) = mpsc::channel::<Result<Bytes, IoError>>(1);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::ai_serving::{
|
|||||||
};
|
};
|
||||||
use crate::clock::current_unix_ms;
|
use crate::clock::current_unix_ms;
|
||||||
use crate::execution_runtime;
|
use crate::execution_runtime;
|
||||||
|
use crate::handlers::admin::provider::write::provider::reconcile_admin_fixed_provider_template_endpoints;
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
||||||
use crate::handlers::shared::provider_pool::{
|
use crate::handlers::shared::provider_pool::{
|
||||||
admin_provider_pool_config_from_config_value, read_admin_provider_pool_runtime_state,
|
admin_provider_pool_config_from_config_value, read_admin_provider_pool_runtime_state,
|
||||||
@@ -555,7 +556,6 @@ fn provider_query_build_test_request_body_with_model_policy(
|
|||||||
"content": provider_query_extract_message(payload)
|
"content": provider_query_extract_message(payload)
|
||||||
.unwrap_or_else(|| DEFAULT_PROVIDER_QUERY_TEST_MESSAGE.to_string())
|
.unwrap_or_else(|| DEFAULT_PROVIDER_QUERY_TEST_MESSAGE.to_string())
|
||||||
}],
|
}],
|
||||||
"max_tokens": 30,
|
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"stream": true,
|
"stream": true,
|
||||||
})
|
})
|
||||||
@@ -1022,6 +1022,8 @@ async fn provider_query_build_kiro_test_candidates(
|
|||||||
payload: &Value,
|
payload: &Value,
|
||||||
requested_model_override: Option<&str>,
|
requested_model_override: Option<&str>,
|
||||||
) -> Result<Vec<ProviderQueryTestCandidate>, Response<Body>> {
|
) -> Result<Vec<ProviderQueryTestCandidate>, Response<Body>> {
|
||||||
|
provider_query_reconcile_fixed_provider_endpoints_for_test_model(state, provider).await?;
|
||||||
|
|
||||||
let provider_ids = vec![provider.id.clone()];
|
let provider_ids = vec![provider.id.clone()];
|
||||||
let endpoints = state
|
let endpoints = state
|
||||||
.app()
|
.app()
|
||||||
@@ -1227,6 +1229,33 @@ async fn provider_query_build_kiro_test_candidates(
|
|||||||
Ok(candidates)
|
Ok(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn provider_query_reconcile_fixed_provider_endpoints_for_test_model(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
provider: &StoredProviderCatalogProvider,
|
||||||
|
) -> Result<(), Response<Body>> {
|
||||||
|
if state
|
||||||
|
.fixed_provider_template(&provider.provider_type)
|
||||||
|
.is_none()
|
||||||
|
|| !state.has_provider_catalog_data_writer()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcile_admin_fixed_provider_template_endpoints(state, provider)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
warn!(
|
||||||
|
provider_id = %provider.id,
|
||||||
|
provider_type = %provider.provider_type,
|
||||||
|
error = ?err,
|
||||||
|
"admin provider-query test-model: failed to reconcile fixed provider endpoints"
|
||||||
|
);
|
||||||
|
build_admin_provider_query_bad_request_response(
|
||||||
|
ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn provider_query_decode_execution_body(
|
fn provider_query_decode_execution_body(
|
||||||
result: &aether_contracts::ExecutionResult,
|
result: &aether_contracts::ExecutionResult,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
@@ -1256,7 +1285,7 @@ fn provider_query_standard_execution_response_body(
|
|||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
result: &aether_contracts::ExecutionResult,
|
result: &aether_contracts::ExecutionResult,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
result
|
let body = result
|
||||||
.body
|
.body
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|body| body.json_body.clone())
|
.and_then(|body| body.json_body.clone())
|
||||||
@@ -1264,7 +1293,15 @@ fn provider_query_standard_execution_response_body(
|
|||||||
provider_query_decode_execution_body(result).and_then(|body| {
|
provider_query_decode_execution_body(result).and_then(|body| {
|
||||||
provider_query_aggregate_standard_stream_sync_response(provider_api_format, &body)
|
provider_query_aggregate_standard_stream_sync_response(provider_api_format, &body)
|
||||||
})
|
})
|
||||||
})
|
})?;
|
||||||
|
if result.status_code < 400
|
||||||
|
&& provider_query_normalize_api_format_alias(provider_api_format)
|
||||||
|
== "gemini:generate_content"
|
||||||
|
&& aether_ai_formats::formats::gemini::generate_content::response::from_raw(&body).is_none()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provider_query_extract_error_message(
|
fn provider_query_extract_error_message(
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ fn provider_query_test_request_body_defaults_missing_model() {
|
|||||||
assert_eq!(body["model"], json!("fallback-model"));
|
assert_eq!(body["model"], json!("fallback-model"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_query_default_test_request_body_does_not_set_max_tokens() {
|
||||||
|
let body = provider_query_build_test_request_body(&json!({}), "fallback-model");
|
||||||
|
|
||||||
|
assert_eq!(body["model"], json!("fallback-model"));
|
||||||
|
assert!(
|
||||||
|
body.get("max_tokens").is_none(),
|
||||||
|
"admin model test must not silently force a low max_tokens value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_query_failover_request_body_overrides_custom_model() {
|
fn provider_query_failover_request_body_overrides_custom_model() {
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
@@ -168,6 +179,40 @@ fn provider_query_standard_test_aggregates_responses_stream_body() {
|
|||||||
assert_eq!(body["output"][0]["content"][0]["text"], json!("Hello"));
|
assert_eq!(body["output"][0]["content"][0]["text"], json!("Hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_query_standard_test_rejects_gemini_success_without_visible_output() {
|
||||||
|
let result = aether_contracts::ExecutionResult {
|
||||||
|
request_id: "provider-test".to_string(),
|
||||||
|
candidate_id: Some("candidate-0".to_string()),
|
||||||
|
status_code: 200,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Some(aether_contracts::ResponseBody {
|
||||||
|
json_body: Some(json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {"role": "model"},
|
||||||
|
"finishReason": "MAX_TOKENS"
|
||||||
|
}],
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 8,
|
||||||
|
"candidatesTokenCount": 1,
|
||||||
|
"thoughtsTokenCount": 25,
|
||||||
|
"totalTokenCount": 34
|
||||||
|
},
|
||||||
|
"modelVersion": "gemini-3-flash-preview",
|
||||||
|
"responseId": "resp-empty"
|
||||||
|
})),
|
||||||
|
body_bytes_b64: None,
|
||||||
|
}),
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
provider_query_standard_execution_response_body("gemini:generate_content", &result)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_query_test_adapter_routes_fixed_provider_endpoint_types() {
|
fn provider_query_test_adapter_routes_fixed_provider_endpoint_types() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -179,9 +179,6 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
|
|||||||
"role": "user",
|
"role": "user",
|
||||||
"parts": [{"text": "Health check"}],
|
"parts": [{"text": "Health check"}],
|
||||||
}],
|
}],
|
||||||
"generationConfig": {
|
|
||||||
"maxOutputTokens": 5,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,6 +129,56 @@ fn gemini_embedding_success_state(
|
|||||||
.with_data_state_for_tests(data_state)
|
.with_data_state_for_tests(data_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vertex_gemini_embedding_success_state(execution_runtime_url: String) -> AppState {
|
||||||
|
let mut snapshot = sample_currently_usable_auth_snapshot(
|
||||||
|
"key-vertex-gemini-embedding-success",
|
||||||
|
"user-vertex-gemini-embedding-success",
|
||||||
|
);
|
||||||
|
snapshot.user_allowed_providers = None;
|
||||||
|
snapshot.api_key_allowed_providers = Some(vec!["openai".to_string(), "vertex_ai".to_string()]);
|
||||||
|
snapshot.user_allowed_api_formats = Some(vec!["openai:embedding".to_string()]);
|
||||||
|
snapshot.api_key_allowed_api_formats = Some(vec!["openai:embedding".to_string()]);
|
||||||
|
snapshot.user_allowed_models = Some(vec!["gemini-embedding-2-preview".to_string()]);
|
||||||
|
snapshot.api_key_allowed_models = Some(vec!["gemini-embedding-2-preview".to_string()]);
|
||||||
|
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||||
|
Some(hash_api_key("sk-vertex-gemini-embedding-success")),
|
||||||
|
snapshot,
|
||||||
|
)]));
|
||||||
|
let candidate_repository =
|
||||||
|
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||||
|
vertex_gemini_embedding_candidate_row(),
|
||||||
|
]));
|
||||||
|
let mut provider = sample_provider("provider-vertex-gemini-embedding", "Vertex AI", 1);
|
||||||
|
provider.provider_type = "vertex_ai".to_string();
|
||||||
|
let mut key = sample_key(
|
||||||
|
"key-upstream-vertex-gemini-embedding",
|
||||||
|
"provider-vertex-gemini-embedding",
|
||||||
|
"gemini:embedding",
|
||||||
|
"sk-upstream-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-gemini-embedding",
|
||||||
|
"gemini:embedding",
|
||||||
|
"https://aiplatform.googleapis.com",
|
||||||
|
)],
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let data_state =
|
||||||
|
GatewayDataState::with_provider_catalog_and_minimal_candidate_selection_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
candidate_repository,
|
||||||
|
)
|
||||||
|
.with_auth_api_key_reader(auth_repository)
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||||
|
|
||||||
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||||
|
.with_data_state_for_tests(data_state)
|
||||||
|
}
|
||||||
|
|
||||||
fn gemini_embedding_conversion_execution_runtime() -> Router {
|
fn gemini_embedding_conversion_execution_runtime() -> Router {
|
||||||
Router::new().route(
|
Router::new().route(
|
||||||
"/v1/execute/sync",
|
"/v1/execute/sync",
|
||||||
@@ -139,6 +189,16 @@ fn gemini_embedding_conversion_execution_runtime() -> Router {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vertex_gemini_embedding_conversion_execution_runtime() -> Router {
|
||||||
|
Router::new().route(
|
||||||
|
"/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))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn gemini_embedding_batch_conversion_execution_runtime() -> Router {
|
fn gemini_embedding_batch_conversion_execution_runtime() -> Router {
|
||||||
Router::new().route(
|
Router::new().route(
|
||||||
"/v1/execute/sync",
|
"/v1/execute/sync",
|
||||||
@@ -227,6 +287,19 @@ fn gemini_embedding_candidate_row() -> StoredMinimalCandidateSelectionRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vertex_gemini_embedding_candidate_row() -> StoredMinimalCandidateSelectionRow {
|
||||||
|
let mut row = gemini_embedding_candidate_row();
|
||||||
|
row.provider_id = "provider-vertex-gemini-embedding".to_string();
|
||||||
|
row.provider_name = "Vertex AI".to_string();
|
||||||
|
row.provider_type = "vertex_ai".to_string();
|
||||||
|
row.endpoint_id = "endpoint-vertex-gemini-embedding".to_string();
|
||||||
|
row.key_id = "key-upstream-vertex-gemini-embedding".to_string();
|
||||||
|
row.key_name = "default".to_string();
|
||||||
|
row.key_allowed_models = Some(vec!["gemini-embedding-2".to_string()]);
|
||||||
|
row.model_provider_model_name = "gemini-embedding-2".to_string();
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_embedding_execution_plan(plan: &ExecutionPlan) {
|
fn assert_embedding_execution_plan(plan: &ExecutionPlan) {
|
||||||
assert_eq!(plan.client_api_format, "openai:embedding");
|
assert_eq!(plan.client_api_format, "openai:embedding");
|
||||||
assert_eq!(plan.provider_api_format, "openai:embedding");
|
assert_eq!(plan.provider_api_format, "openai:embedding");
|
||||||
@@ -262,6 +335,30 @@ fn assert_openai_to_gemini_embedding_execution_plan(plan: &ExecutionPlan) {
|
|||||||
assert!(body.get("messages").is_none());
|
assert!(body.get("messages").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_openai_to_vertex_gemini_embedding_execution_plan(plan: &ExecutionPlan) {
|
||||||
|
assert_eq!(plan.provider_id, "provider-vertex-gemini-embedding");
|
||||||
|
assert_eq!(plan.client_api_format, "openai:embedding");
|
||||||
|
assert_eq!(plan.provider_api_format, "gemini:embedding");
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.model_name.as_deref(),
|
||||||
|
Some("gemini-embedding-2-preview")
|
||||||
|
);
|
||||||
|
assert!(!plan.stream);
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
assert_eq!(body["content"]["parts"][0]["text"], "hello");
|
||||||
|
assert!(body.get("input").is_none());
|
||||||
|
assert!(body.get("messages").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_openai_to_gemini_batch_embedding_execution_plan(plan: &ExecutionPlan) {
|
fn assert_openai_to_gemini_batch_embedding_execution_plan(plan: &ExecutionPlan) {
|
||||||
assert_eq!(plan.client_api_format, "openai:embedding");
|
assert_eq!(plan.client_api_format, "openai:embedding");
|
||||||
assert_eq!(plan.provider_api_format, "gemini:embedding");
|
assert_eq!(plan.provider_api_format, "gemini:embedding");
|
||||||
@@ -502,6 +599,50 @@ async fn embeddings_route_converts_openai_payload_to_gemini_embedding_provider()
|
|||||||
execution_runtime_handle.abort();
|
execution_runtime_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn embeddings_route_converts_openai_payload_to_vertex_gemini_embedding_provider() {
|
||||||
|
let (execution_runtime_url, execution_runtime_handle) =
|
||||||
|
start_server(vertex_gemini_embedding_conversion_execution_runtime()).await;
|
||||||
|
let gateway =
|
||||||
|
build_router_with_state(vertex_gemini_embedding_success_state(execution_runtime_url));
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/v1/embeddings"))
|
||||||
|
.header(
|
||||||
|
http::header::AUTHORIZATION,
|
||||||
|
"Bearer sk-vertex-gemini-embedding-success",
|
||||||
|
)
|
||||||
|
.json(&json!({
|
||||||
|
"model": "gemini-embedding-2-preview",
|
||||||
|
"input": "hello"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
let endpoint_signature = response
|
||||||
|
.headers()
|
||||||
|
.get(CONTROL_ENDPOINT_SIGNATURE_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::to_string);
|
||||||
|
let status = response.status();
|
||||||
|
let body_text = response.text().await.expect("body should read");
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::OK,
|
||||||
|
"unexpected response body: {body_text}"
|
||||||
|
);
|
||||||
|
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["data"][0]["embedding"], json!([0.1, 0.2, 0.3]));
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
execution_runtime_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn embeddings_route_converts_openai_batch_payload_to_gemini_batch_endpoint() {
|
async fn embeddings_route_converts_openai_batch_payload_to_gemini_batch_endpoint() {
|
||||||
let (execution_runtime_url, execution_runtime_handle) =
|
let (execution_runtime_url, execution_runtime_handle) =
|
||||||
|
|||||||
@@ -1978,6 +1978,91 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
|
|||||||
provider_handle.abort();
|
provider_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_gemini_test_connection_does_not_force_low_max_output_tokens() {
|
||||||
|
let provider_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let provider_hits_clone = Arc::clone(&provider_hits);
|
||||||
|
let provider = Router::new().route(
|
||||||
|
"/{*path}",
|
||||||
|
any(move |request: Request| {
|
||||||
|
let provider_hits_inner = Arc::clone(&provider_hits_clone);
|
||||||
|
async move {
|
||||||
|
*provider_hits_inner.lock().expect("mutex should lock") += 1;
|
||||||
|
let body = to_bytes(request.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read");
|
||||||
|
let body_json: serde_json::Value =
|
||||||
|
serde_json::from_slice(&body).expect("json body should parse");
|
||||||
|
assert_eq!(body_json["contents"][0]["parts"][0]["text"], "Health check");
|
||||||
|
assert!(
|
||||||
|
body_json
|
||||||
|
.get("generationConfig")
|
||||||
|
.and_then(|config| config.get("maxOutputTokens"))
|
||||||
|
.is_none(),
|
||||||
|
"Gemini test connection must not force a tiny maxOutputTokens value"
|
||||||
|
);
|
||||||
|
Json(json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"role": "model",
|
||||||
|
"parts": [{"text": "ok"}]
|
||||||
|
},
|
||||||
|
"finishReason": "STOP"
|
||||||
|
}],
|
||||||
|
"responseId": "gemini_test_connection_ok"
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (provider_url, provider_handle) = start_server(provider).await;
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![sample_provider("provider-gemini", "google", 10)],
|
||||||
|
vec![sample_endpoint(
|
||||||
|
"endpoint-gemini",
|
||||||
|
"provider-gemini",
|
||||||
|
"gemini:generate_content",
|
||||||
|
&provider_url,
|
||||||
|
)],
|
||||||
|
vec![sample_key(
|
||||||
|
"key-gemini",
|
||||||
|
"provider-gemini",
|
||||||
|
"gemini:generate_content",
|
||||||
|
"google-api-key",
|
||||||
|
)],
|
||||||
|
));
|
||||||
|
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(format!(
|
||||||
|
"{gateway_url}/v1/test-connection?provider=provider-gemini&model=gemini-3-flash-preview&api_format=gemini:generate_content"
|
||||||
|
))
|
||||||
|
.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["status"], "success");
|
||||||
|
assert_eq!(payload["provider_id"], "provider-gemini");
|
||||||
|
assert_eq!(payload["endpoint_id"], "endpoint-gemini");
|
||||||
|
assert_eq!(payload["api_format"], "gemini:generate_content");
|
||||||
|
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 1);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
provider_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
async fn assert_public_support_route_returns_local_503(
|
async fn assert_public_support_route_returns_local_503(
|
||||||
method: reqwest::Method,
|
method: reqwest::Method,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
|||||||
extensions: Default::default(),
|
extensions: Default::default(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
outputs.retain(gemini_response_output_has_visible_content);
|
||||||
|
if outputs.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let content = outputs
|
let content = outputs
|
||||||
.first()
|
.first()
|
||||||
.map(|output| output.content.clone())
|
.map(|output| output.content.clone())
|
||||||
@@ -108,6 +112,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
|||||||
Some(canonical)
|
Some(canonical)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gemini_response_output_has_visible_content(output: &CanonicalResponseOutput) -> bool {
|
||||||
|
output.content.iter().any(|block| match block {
|
||||||
|
CanonicalContentBlock::Text { text, .. } => !text.trim().is_empty(),
|
||||||
|
CanonicalContentBlock::ToolUse { .. }
|
||||||
|
| CanonicalContentBlock::ToolResult { .. }
|
||||||
|
| CanonicalContentBlock::Image { .. }
|
||||||
|
| CanonicalContentBlock::File { .. }
|
||||||
|
| CanonicalContentBlock::Audio { .. } => true,
|
||||||
|
CanonicalContentBlock::Thinking { .. } | CanonicalContentBlock::Unknown { .. } => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option<Value> {
|
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option<Value> {
|
||||||
let mut response = canonical_to_gemini_response(canonical, report_context)?;
|
let mut response = canonical_to_gemini_response(canonical, report_context)?;
|
||||||
if let Some(object) = response.as_object_mut() {
|
if let Some(object) = response.as_object_mut() {
|
||||||
@@ -353,3 +369,72 @@ fn canonical_usage_to_gemini_usage_metadata(usage: &CanonicalUsage) -> Value {
|
|||||||
}
|
}
|
||||||
Value::Object(out)
|
Value::Object(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::CanonicalContentBlock;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_response_without_visible_parts_is_not_success() {
|
||||||
|
let body = json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {"role": "model"},
|
||||||
|
"finishReason": "MAX_TOKENS"
|
||||||
|
}],
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 8,
|
||||||
|
"candidatesTokenCount": 1,
|
||||||
|
"thoughtsTokenCount": 25,
|
||||||
|
"totalTokenCount": 34
|
||||||
|
},
|
||||||
|
"modelVersion": "gemini-3-flash-preview",
|
||||||
|
"responseId": "resp-empty"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(from_raw(&body).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_response_with_only_thought_parts_is_not_success() {
|
||||||
|
let body = json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"role": "model",
|
||||||
|
"parts": [{"text": "hidden plan", "thought": true}]
|
||||||
|
},
|
||||||
|
"finishReason": "MAX_TOKENS"
|
||||||
|
}],
|
||||||
|
"modelVersion": "gemini-3-flash-preview",
|
||||||
|
"responseId": "resp-thought-only"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(from_raw(&body).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_response_with_function_call_is_visible_output() {
|
||||||
|
let body = json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"role": "model",
|
||||||
|
"parts": [{
|
||||||
|
"functionCall": {
|
||||||
|
"name": "lookup",
|
||||||
|
"args": {"query": "weather"}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finishReason": "STOP"
|
||||||
|
}],
|
||||||
|
"modelVersion": "gemini-3-flash-preview",
|
||||||
|
"responseId": "resp-tool"
|
||||||
|
});
|
||||||
|
|
||||||
|
let canonical = from_raw(&body).expect("function call should be visible output");
|
||||||
|
assert!(matches!(
|
||||||
|
canonical.content.first(),
|
||||||
|
Some(CanonicalContentBlock::ToolUse { name, .. }) if name == "lookup"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -317,11 +317,15 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
|
|||||||
auth_type == "oauth" && api_format == "gemini:generate_content"
|
auth_type == "oauth" && api_format == "gemini:generate_content"
|
||||||
}
|
}
|
||||||
"vertex_ai" => {
|
"vertex_ai" => {
|
||||||
(auth_type == "api_key" && api_format == "gemini:generate_content")
|
(auth_type == "api_key"
|
||||||
|
&& matches!(
|
||||||
|
api_format.as_str(),
|
||||||
|
"gemini:generate_content" | "gemini:embedding"
|
||||||
|
))
|
||||||
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
||||||
&& matches!(
|
&& matches!(
|
||||||
api_format.as_str(),
|
api_format.as_str(),
|
||||||
"claude:messages" | "gemini:generate_content"
|
"claude:messages" | "gemini:generate_content" | "gemini:embedding"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => auth_type != "oauth",
|
_ => auth_type != "oauth",
|
||||||
|
|||||||
@@ -446,11 +446,15 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
|||||||
auth_type == "oauth" && api_format == "gemini:generate_content"
|
auth_type == "oauth" && api_format == "gemini:generate_content"
|
||||||
}
|
}
|
||||||
"vertex_ai" => {
|
"vertex_ai" => {
|
||||||
(auth_type == "api_key" && api_format == "gemini:generate_content")
|
(auth_type == "api_key"
|
||||||
|
&& matches!(
|
||||||
|
api_format.as_str(),
|
||||||
|
"gemini:generate_content" | "gemini:embedding"
|
||||||
|
))
|
||||||
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
||||||
&& matches!(
|
&& matches!(
|
||||||
api_format.as_str(),
|
api_format.as_str(),
|
||||||
"claude:messages" | "gemini:generate_content"
|
"claude:messages" | "gemini:generate_content" | "gemini:embedding"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => auth_type != "oauth",
|
_ => auth_type != "oauth",
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ WHERE p.is_active = TRUE
|
|||||||
AND (
|
AND (
|
||||||
(
|
(
|
||||||
LOWER(BTRIM(pak.auth_type)) = 'api_key'
|
LOWER(BTRIM(pak.auth_type)) = 'api_key'
|
||||||
AND LOWER($3) = 'gemini:generate_content'
|
AND LOWER($3) IN ('gemini:generate_content', 'gemini:embedding')
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
LOWER(BTRIM(pak.auth_type)) IN ('service_account', 'vertex_ai')
|
LOWER(BTRIM(pak.auth_type)) IN ('service_account', 'vertex_ai')
|
||||||
AND LOWER($3) IN ('claude:messages', 'gemini:generate_content')
|
AND LOWER($3) IN ('claude:messages', 'gemini:generate_content', 'gemini:embedding')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -296,11 +296,11 @@ WHERE p.is_active = TRUE
|
|||||||
AND (
|
AND (
|
||||||
(
|
(
|
||||||
LOWER(BTRIM(pak.auth_type)) = 'api_key'
|
LOWER(BTRIM(pak.auth_type)) = 'api_key'
|
||||||
AND LOWER($4) = 'gemini:generate_content'
|
AND LOWER($4) IN ('gemini:generate_content', 'gemini:embedding')
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
LOWER(BTRIM(pak.auth_type)) IN ('service_account', 'vertex_ai')
|
LOWER(BTRIM(pak.auth_type)) IN ('service_account', 'vertex_ai')
|
||||||
AND LOWER($4) IN ('claude:messages', 'gemini:generate_content')
|
AND LOWER($4) IN ('claude:messages', 'gemini:generate_content', 'gemini:embedding')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -478,11 +478,11 @@ WHERE p.is_active = TRUE
|
|||||||
AND (
|
AND (
|
||||||
(
|
(
|
||||||
LOWER(BTRIM(pak.auth_type)) = 'api_key'
|
LOWER(BTRIM(pak.auth_type)) = 'api_key'
|
||||||
AND LOWER($6) = 'gemini:generate_content'
|
AND LOWER($6) IN ('gemini:generate_content', 'gemini:embedding')
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
LOWER(BTRIM(pak.auth_type)) IN ('service_account', 'vertex_ai')
|
LOWER(BTRIM(pak.auth_type)) IN ('service_account', 'vertex_ai')
|
||||||
AND LOWER($6) IN ('claude:messages', 'gemini:generate_content')
|
AND LOWER($6) IN ('claude:messages', 'gemini:generate_content', 'gemini:embedding')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1287,6 +1287,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn candidate_selection_sql_allows_vertex_embedding_auth() {
|
||||||
|
let requested_model_sql = requested_model_selection_sql();
|
||||||
|
for sql in [
|
||||||
|
LIST_FOR_EXACT_API_FORMAT_SQL,
|
||||||
|
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL,
|
||||||
|
LIST_POOL_KEYS_FOR_GROUP_SQL,
|
||||||
|
requested_model_sql.as_str(),
|
||||||
|
] {
|
||||||
|
assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'vertex_ai'"));
|
||||||
|
assert!(sql.contains("gemini:embedding"));
|
||||||
|
assert!(sql.contains("gemini:generate_content"));
|
||||||
|
assert!(sql.contains("claude:messages"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn requested_model_selection_page_sql_adds_limit_and_offset() {
|
fn requested_model_selection_page_sql_adds_limit_and_offset() {
|
||||||
let sql = requested_model_selection_page_sql();
|
let sql = requested_model_selection_page_sql();
|
||||||
|
|||||||
@@ -446,11 +446,15 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
|||||||
auth_type == "oauth" && api_format == "gemini:generate_content"
|
auth_type == "oauth" && api_format == "gemini:generate_content"
|
||||||
}
|
}
|
||||||
"vertex_ai" => {
|
"vertex_ai" => {
|
||||||
(auth_type == "api_key" && api_format == "gemini:generate_content")
|
(auth_type == "api_key"
|
||||||
|
&& matches!(
|
||||||
|
api_format.as_str(),
|
||||||
|
"gemini:generate_content" | "gemini:embedding"
|
||||||
|
))
|
||||||
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
|| (matches!(auth_type.as_str(), "service_account" | "vertex_ai")
|
||||||
&& matches!(
|
&& matches!(
|
||||||
api_format.as_str(),
|
api_format.as_str(),
|
||||||
"claude:messages" | "gemini:generate_content"
|
"claude:messages" | "gemini:generate_content" | "gemini:embedding"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => auth_type != "oauth",
|
_ => auth_type != "oauth",
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ mod tests {
|
|||||||
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
|
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
|
||||||
request_conversion_direct_auth, request_conversion_enabled_for_transport,
|
request_conversion_direct_auth, request_conversion_enabled_for_transport,
|
||||||
request_conversion_transport_supported, request_pair_allowed_for_transport,
|
request_conversion_transport_supported, request_pair_allowed_for_transport,
|
||||||
CandidateTransportPolicyFacts,
|
request_pair_direct_auth, CandidateTransportPolicyFacts,
|
||||||
};
|
};
|
||||||
use aether_ai_formats::formats::matrix::RequestConversionKind;
|
use aether_ai_formats::formats::matrix::RequestConversionKind;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -593,6 +593,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_gemini_embedding_transport_supports_openai_embedding_conversion() {
|
||||||
|
let transport = transport_snapshot("vertex_ai", "gemini:embedding", "api_key", true, None);
|
||||||
|
|
||||||
|
assert!(request_pair_allowed_for_transport(
|
||||||
|
&transport,
|
||||||
|
"openai:embedding",
|
||||||
|
"gemini:embedding"
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
request_pair_direct_auth(&transport, "gemini:embedding"),
|
||||||
|
Some(("key".to_string(), "secret".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kiro_claude_messages_transport_supports_cross_format_conversion_via_envelope() {
|
fn kiro_claude_messages_transport_supports_cross_format_conversion_via_envelope() {
|
||||||
let transport = transport_snapshot("kiro", "claude:messages", "bearer", true, None);
|
let transport = transport_snapshot("kiro", "claude:messages", "bearer", true, None);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub enum ProviderApiFormatInheritance {
|
|||||||
None,
|
None,
|
||||||
OAuth,
|
OAuth,
|
||||||
OAuthOrBearer,
|
OAuthOrBearer,
|
||||||
|
OAuthOrServiceAccount,
|
||||||
OAuthOrConfiguredBearer,
|
OAuthOrConfiguredBearer,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,9 @@ impl ProviderApiFormatInheritance {
|
|||||||
Self::None => false,
|
Self::None => false,
|
||||||
Self::OAuth => auth_type == "oauth",
|
Self::OAuth => auth_type == "oauth",
|
||||||
Self::OAuthOrBearer => auth_type == "oauth" || auth_type == "bearer",
|
Self::OAuthOrBearer => auth_type == "oauth" || auth_type == "bearer",
|
||||||
|
Self::OAuthOrServiceAccount => {
|
||||||
|
auth_type == "oauth" || auth_type == "service_account" || auth_type == "vertex_ai"
|
||||||
|
}
|
||||||
Self::OAuthOrConfiguredBearer => {
|
Self::OAuthOrConfiguredBearer => {
|
||||||
auth_type == "oauth"
|
auth_type == "oauth"
|
||||||
|| auth_type == "bearer"
|
|| auth_type == "bearer"
|
||||||
@@ -221,11 +225,12 @@ const GEMINI_CLI_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
|||||||
};
|
};
|
||||||
const VERTEX_AI_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
const VERTEX_AI_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||||
fixed_provider: true,
|
fixed_provider: true,
|
||||||
api_format_inheritance: ProviderApiFormatInheritance::OAuth,
|
api_format_inheritance: ProviderApiFormatInheritance::OAuthOrServiceAccount,
|
||||||
enable_format_conversion_by_default: true,
|
enable_format_conversion_by_default: true,
|
||||||
supports_model_fetch: false,
|
supports_model_fetch: false,
|
||||||
supports_local_openai_chat_transport: false,
|
supports_local_openai_chat_transport: false,
|
||||||
supports_local_same_format_transport: false,
|
supports_local_same_format_transport: false,
|
||||||
|
local_embedding_support: ProviderLocalEmbeddingSupport::Gemini,
|
||||||
..STANDARD_RUNTIME_POLICY
|
..STANDARD_RUNTIME_POLICY
|
||||||
};
|
};
|
||||||
const ANTIGRAVITY_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
const ANTIGRAVITY_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||||
@@ -329,6 +334,12 @@ const VERTEX_AI_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTe
|
|||||||
custom_path: None,
|
custom_path: None,
|
||||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||||
},
|
},
|
||||||
|
FixedProviderEndpointTemplate {
|
||||||
|
item_key: "gemini:embedding",
|
||||||
|
api_format: "gemini:embedding",
|
||||||
|
custom_path: None,
|
||||||
|
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||||
|
},
|
||||||
FixedProviderEndpointTemplate {
|
FixedProviderEndpointTemplate {
|
||||||
item_key: "claude:messages",
|
item_key: "claude:messages",
|
||||||
api_format: "claude:messages",
|
api_format: "claude:messages",
|
||||||
@@ -613,6 +624,11 @@ mod tests {
|
|||||||
"bearer",
|
"bearer",
|
||||||
Some("{}")
|
Some("{}")
|
||||||
));
|
));
|
||||||
|
assert!(fixed_provider_key_inherits_api_formats(
|
||||||
|
"vertex_ai",
|
||||||
|
"service_account",
|
||||||
|
None
|
||||||
|
));
|
||||||
assert!(!fixed_provider_key_inherits_api_formats(
|
assert!(!fixed_provider_key_inherits_api_formats(
|
||||||
"kiro", "bearer", None
|
"kiro", "bearer", None
|
||||||
));
|
));
|
||||||
@@ -681,6 +697,7 @@ mod tests {
|
|||||||
("custom", "openai:embedding"),
|
("custom", "openai:embedding"),
|
||||||
("gemini", "gemini:embedding"),
|
("gemini", "gemini:embedding"),
|
||||||
("google", "gemini:embedding"),
|
("google", "gemini:embedding"),
|
||||||
|
("vertex_ai", "gemini:embedding"),
|
||||||
("jina", "jina:embedding"),
|
("jina", "jina:embedding"),
|
||||||
("doubao", "doubao:embedding"),
|
("doubao", "doubao:embedding"),
|
||||||
("volcengine", "doubao:embedding"),
|
("volcengine", "doubao:embedding"),
|
||||||
@@ -694,6 +711,7 @@ mod tests {
|
|||||||
for (provider_type, api_format) in [
|
for (provider_type, api_format) in [
|
||||||
("openai", "gemini:embedding"),
|
("openai", "gemini:embedding"),
|
||||||
("gemini", "openai:embedding"),
|
("gemini", "openai:embedding"),
|
||||||
|
("vertex_ai", "openai:embedding"),
|
||||||
("jina", "doubao:embedding"),
|
("jina", "doubao:embedding"),
|
||||||
("doubao", "jina:embedding"),
|
("doubao", "jina:embedding"),
|
||||||
("claude_code", "openai:embedding"),
|
("claude_code", "openai:embedding"),
|
||||||
@@ -710,4 +728,28 @@ mod tests {
|
|||||||
"GEMINI:EMBEDDING"
|
"GEMINI:EMBEDDING"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_fixed_provider_template_includes_gemini_embedding_endpoint() {
|
||||||
|
let template =
|
||||||
|
fixed_provider_template("vertex_ai").expect("vertex_ai template should exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
template
|
||||||
|
.endpoints
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.api_format)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"gemini:generate_content",
|
||||||
|
"gemini:embedding",
|
||||||
|
"claude:messages",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
fixed_provider_endpoint_template_by_api_format("vertex_ai", "gemini:embedding")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ use crate::url::{
|
|||||||
build_openai_responses_url, build_passthrough_path_url, normalize_gemini_content_action_path,
|
build_openai_responses_url, build_passthrough_path_url, normalize_gemini_content_action_path,
|
||||||
};
|
};
|
||||||
use crate::vertex::{
|
use crate::vertex::{
|
||||||
build_vertex_api_key_gemini_content_url, build_vertex_service_account_gemini_content_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_embedding_url, is_vertex_transport_context,
|
||||||
resolve_local_vertex_api_key_query_auth, resolve_local_vertex_service_account_auth_config,
|
resolve_local_vertex_api_key_query_auth, resolve_local_vertex_service_account_auth_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,13 +64,20 @@ fn build_transport_request_url_inner(
|
|||||||
params: TransportRequestUrlParams<'_>,
|
params: TransportRequestUrlParams<'_>,
|
||||||
gemini_embedding_batch: bool,
|
gemini_embedding_batch: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
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) {
|
if let Some(url) = build_transport_hook_url(transport, params) {
|
||||||
return Some(url);
|
return Some(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
let custom_path = transport
|
let custom_path = transport
|
||||||
.endpoint
|
.endpoint
|
||||||
.custom_path
|
.custom_path
|
||||||
@@ -281,25 +290,42 @@ fn build_transport_hook_url(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if aether_ai_formats::normalize_api_format_alias(params.provider_api_format)
|
match aether_ai_formats::normalize_api_format_alias(params.provider_api_format).as_str() {
|
||||||
== "gemini:generate_content"
|
"gemini:generate_content" => {
|
||||||
{
|
if let Some(auth) = resolve_local_vertex_api_key_query_auth(transport) {
|
||||||
if let Some(auth) = resolve_local_vertex_api_key_query_auth(transport) {
|
return build_vertex_api_key_gemini_content_url(
|
||||||
return build_vertex_api_key_gemini_content_url(
|
params.mapped_model?,
|
||||||
params.mapped_model?,
|
params.upstream_is_stream,
|
||||||
params.upstream_is_stream,
|
&auth.value,
|
||||||
&auth.value,
|
params.request_query,
|
||||||
params.request_query,
|
);
|
||||||
);
|
}
|
||||||
|
if let Some(auth_config) = resolve_local_vertex_service_account_auth_config(transport) {
|
||||||
|
return build_vertex_service_account_gemini_content_url(
|
||||||
|
params.mapped_model?,
|
||||||
|
params.upstream_is_stream,
|
||||||
|
&auth_config,
|
||||||
|
params.request_query,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(auth_config) = resolve_local_vertex_service_account_auth_config(transport) {
|
"gemini:embedding" => {
|
||||||
return build_vertex_service_account_gemini_content_url(
|
if let Some(auth) = resolve_local_vertex_api_key_query_auth(transport) {
|
||||||
params.mapped_model?,
|
return build_vertex_api_key_gemini_embedding_url(
|
||||||
params.upstream_is_stream,
|
params.mapped_model?,
|
||||||
&auth_config,
|
&auth.value,
|
||||||
params.request_query,
|
params.request_query,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if let Some(auth_config) = resolve_local_vertex_service_account_auth_config(transport) {
|
||||||
|
return build_vertex_service_account_gemini_embedding_url(
|
||||||
|
params.mapped_model?,
|
||||||
|
&auth_config,
|
||||||
|
params.request_query,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_antigravity_provider_transport(transport) {
|
if is_antigravity_provider_transport(transport) {
|
||||||
@@ -629,6 +655,91 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_vertex_service_account_hook_for_gemini_embedding_url() {
|
||||||
|
let mut transport = sample_transport(
|
||||||
|
"vertex_ai",
|
||||||
|
"gemini:embedding",
|
||||||
|
"https://aiplatform.googleapis.com",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
transport.endpoint.endpoint_kind = Some("embedding".to_string());
|
||||||
|
transport.key.auth_type = "service_account".to_string();
|
||||||
|
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||||
|
transport.key.decrypted_auth_config = Some(
|
||||||
|
r#"{
|
||||||
|
"client_email":"svc@example.iam.gserviceaccount.com",
|
||||||
|
"private_key":"TEST-PRIVATE-KEY",
|
||||||
|
"project_id":"demo-project"
|
||||||
|
}"#
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider_request_body = json!({
|
||||||
|
"content": {"parts": [{"text": "hello"}]}
|
||||||
|
});
|
||||||
|
let url = 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: Some("foo=bar&beta=1"),
|
||||||
|
kiro_api_region: None,
|
||||||
|
},
|
||||||
|
Some(&provider_request_body),
|
||||||
|
)
|
||||||
|
.expect("vertex embedding service account hook url");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/publishers/google/models/gemini-embedding-2:embedContent?foo=bar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_gemini_embedding_batch_request_does_not_use_gemini_api_batch_endpoint() {
|
||||||
|
let mut transport = sample_transport(
|
||||||
|
"vertex_ai",
|
||||||
|
"gemini:embedding",
|
||||||
|
"https://aiplatform.googleapis.com",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
transport.endpoint.endpoint_kind = Some("embedding".to_string());
|
||||||
|
transport.key.auth_type = "service_account".to_string();
|
||||||
|
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||||
|
transport.key.decrypted_auth_config = Some(
|
||||||
|
r#"{
|
||||||
|
"client_email":"svc@example.iam.gserviceaccount.com",
|
||||||
|
"private_key":"TEST-PRIVATE-KEY",
|
||||||
|
"project_id":"demo-project"
|
||||||
|
}"#
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let batch_body = json!({
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"model": "models/gemini-embedding-2",
|
||||||
|
"content": {"parts": [{"text": "alpha"}]}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_openai_responses_url_for_formal_format_name() {
|
fn builds_openai_responses_url_for_formal_format_name() {
|
||||||
let transport = sample_transport(
|
let transport = sample_transport(
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ pub use policy::{
|
|||||||
supports_local_vertex_gemini_transport_with_network,
|
supports_local_vertex_gemini_transport_with_network,
|
||||||
};
|
};
|
||||||
pub use url::{
|
pub use url::{
|
||||||
build_vertex_api_key_gemini_content_url, build_vertex_api_key_imagen_content_url,
|
build_vertex_api_key_gemini_content_url, build_vertex_api_key_gemini_embedding_url,
|
||||||
build_vertex_service_account_gemini_content_url, resolve_vertex_service_account_region,
|
build_vertex_api_key_imagen_content_url, build_vertex_service_account_gemini_content_url,
|
||||||
|
build_vertex_service_account_gemini_embedding_url, resolve_vertex_service_account_region,
|
||||||
VERTEX_API_KEY_BASE_URL,
|
VERTEX_API_KEY_BASE_URL,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ fn local_vertex_gemini_transport_unsupported_reason_with_network_impl(
|
|||||||
Some("key_inactive")
|
Some("key_inactive")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if aether_ai_formats::normalize_api_format_alias(&transport.endpoint.api_format)
|
let endpoint_api_format =
|
||||||
!= "gemini:generate_content"
|
aether_ai_formats::normalize_api_format_alias(&transport.endpoint.api_format);
|
||||||
{
|
if !matches!(
|
||||||
|
endpoint_api_format.as_str(),
|
||||||
|
"gemini:generate_content" | "gemini:embedding"
|
||||||
|
) {
|
||||||
return Some("transport_api_format_mismatch");
|
return Some("transport_api_format_mismatch");
|
||||||
}
|
}
|
||||||
if !is_vertex_transport_family(transport) {
|
if !is_vertex_transport_family(transport) {
|
||||||
@@ -299,6 +302,28 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_vertex_service_account_gemini_embedding_transport_with_network() {
|
||||||
|
let mut transport = sample_transport();
|
||||||
|
transport.endpoint.api_format = "gemini:embedding".to_string();
|
||||||
|
transport.endpoint.endpoint_kind = Some("embedding".to_string());
|
||||||
|
transport.key.api_formats = Some(vec!["gemini:embedding".to_string()]);
|
||||||
|
transport.key.auth_type = "service_account".to_string();
|
||||||
|
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||||
|
transport.key.decrypted_auth_config = Some(
|
||||||
|
r#"{
|
||||||
|
"client_email":"svc@example.iam.gserviceaccount.com",
|
||||||
|
"private_key":"TEST-PRIVATE-KEY",
|
||||||
|
"project_id":"demo-project"
|
||||||
|
}"#
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(supports_local_vertex_gemini_transport_with_network(
|
||||||
|
&transport
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_network_passthrough_for_custom_path_with_local_proxy_support() {
|
fn allows_network_passthrough_for_custom_path_with_local_proxy_support() {
|
||||||
let mut transport = sample_transport();
|
let mut transport = sample_transport();
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ pub fn build_vertex_api_key_gemini_content_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, stream, api_key, request_query)
|
let action = if stream {
|
||||||
|
"streamGenerateContent"
|
||||||
|
} else {
|
||||||
|
"generateContent"
|
||||||
|
};
|
||||||
|
build_vertex_api_key_google_model_url(model, action, stream, api_key, request_query)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_vertex_api_key_imagen_content_url(
|
pub fn build_vertex_api_key_imagen_content_url(
|
||||||
@@ -22,7 +27,20 @@ pub fn build_vertex_api_key_imagen_content_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, stream, api_key, request_query)
|
let action = if stream {
|
||||||
|
"streamGenerateContent"
|
||||||
|
} else {
|
||||||
|
"generateContent"
|
||||||
|
};
|
||||||
|
build_vertex_api_key_google_model_url(model, action, stream, api_key, request_query)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_vertex_api_key_gemini_embedding_url(
|
||||||
|
model: &str,
|
||||||
|
api_key: &str,
|
||||||
|
request_query: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
build_vertex_api_key_google_model_url(model, "embedContent", false, api_key, request_query)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_vertex_service_account_gemini_content_url(
|
pub fn build_vertex_service_account_gemini_content_url(
|
||||||
@@ -31,56 +49,69 @@ pub fn build_vertex_service_account_gemini_content_url(
|
|||||||
auth_config: &VertexServiceAccountAuthConfig,
|
auth_config: &VertexServiceAccountAuthConfig,
|
||||||
request_query: Option<&str>,
|
request_query: Option<&str>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
build_vertex_service_account_google_model_url(model, stream, auth_config, request_query)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_vertex_api_key_google_model_url(
|
|
||||||
model: &str,
|
|
||||||
stream: bool,
|
|
||||||
api_key: &str,
|
|
||||||
request_query: Option<&str>,
|
|
||||||
) -> Option<String> {
|
|
||||||
let trimmed_model = model.trim();
|
|
||||||
let trimmed_api_key = api_key.trim();
|
|
||||||
if trimmed_model.is_empty() || trimmed_api_key.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = if stream {
|
let action = if stream {
|
||||||
"streamGenerateContent"
|
"streamGenerateContent"
|
||||||
} else {
|
} else {
|
||||||
"generateContent"
|
"generateContent"
|
||||||
};
|
};
|
||||||
let path = format!("/v1/publishers/google/models/{trimmed_model}:{action}");
|
build_vertex_service_account_google_model_url(model, action, stream, auth_config, request_query)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_vertex_service_account_gemini_embedding_url(
|
||||||
|
model: &str,
|
||||||
|
auth_config: &VertexServiceAccountAuthConfig,
|
||||||
|
request_query: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
build_vertex_service_account_google_model_url(
|
||||||
|
model,
|
||||||
|
"embedContent",
|
||||||
|
false,
|
||||||
|
auth_config,
|
||||||
|
request_query,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_vertex_api_key_google_model_url(
|
||||||
|
model: &str,
|
||||||
|
action: &str,
|
||||||
|
stream: bool,
|
||||||
|
api_key: &str,
|
||||||
|
request_query: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let trimmed_model = model.trim();
|
||||||
|
let trimmed_action = action.trim();
|
||||||
|
let trimmed_api_key = api_key.trim();
|
||||||
|
if trimmed_model.is_empty() || trimmed_action.is_empty() || trimmed_api_key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/v1/publishers/google/models/{trimmed_model}:{trimmed_action}");
|
||||||
let merged_query = build_vertex_api_key_query(trimmed_api_key, request_query, stream);
|
let merged_query = build_vertex_api_key_query(trimmed_api_key, request_query, stream);
|
||||||
build_passthrough_path_url(VERTEX_API_KEY_BASE_URL, &path, merged_query.as_deref(), &[])
|
build_passthrough_path_url(VERTEX_API_KEY_BASE_URL, &path, merged_query.as_deref(), &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_vertex_service_account_google_model_url(
|
fn build_vertex_service_account_google_model_url(
|
||||||
model: &str,
|
model: &str,
|
||||||
|
action: &str,
|
||||||
stream: bool,
|
stream: bool,
|
||||||
auth_config: &VertexServiceAccountAuthConfig,
|
auth_config: &VertexServiceAccountAuthConfig,
|
||||||
request_query: Option<&str>,
|
request_query: Option<&str>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let trimmed_model = model.trim();
|
let trimmed_model = model.trim();
|
||||||
|
let trimmed_action = action.trim();
|
||||||
let project_id = auth_config.project_id.trim();
|
let project_id = auth_config.project_id.trim();
|
||||||
if trimmed_model.is_empty() || project_id.is_empty() {
|
if trimmed_model.is_empty() || trimmed_action.is_empty() || project_id.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let region = resolve_vertex_service_account_region(trimmed_model, auth_config);
|
let region = resolve_vertex_service_account_region(trimmed_model, auth_config);
|
||||||
let action = if stream {
|
|
||||||
"streamGenerateContent"
|
|
||||||
} else {
|
|
||||||
"generateContent"
|
|
||||||
};
|
|
||||||
let base_url = if region == "global" {
|
let base_url = if region == "global" {
|
||||||
VERTEX_API_KEY_BASE_URL.to_string()
|
VERTEX_API_KEY_BASE_URL.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("https://{region}-aiplatform.googleapis.com")
|
format!("https://{region}-aiplatform.googleapis.com")
|
||||||
};
|
};
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/v1/projects/{project_id}/locations/{region}/publishers/google/models/{trimmed_model}:{action}"
|
"/v1/projects/{project_id}/locations/{region}/publishers/google/models/{trimmed_model}:{trimmed_action}"
|
||||||
);
|
);
|
||||||
let merged_query = build_vertex_service_account_query(request_query, stream);
|
let merged_query = build_vertex_service_account_query(request_query, stream);
|
||||||
build_passthrough_path_url(&base_url, &path, merged_query.as_deref(), &[])
|
build_passthrough_path_url(&base_url, &path, merged_query.as_deref(), &[])
|
||||||
|
|||||||
347
docs/architecture/gemini-api-endpoint-routing.md
Normal file
347
docs/architecture/gemini-api-endpoint-routing.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# Gemini API Endpoint Routing Design
|
||||||
|
|
||||||
|
**状态:** implementation design
|
||||||
|
**最后更新:** 2026-05-17
|
||||||
|
**目标:** 把 Gemini Developer API 和 Vertex AI 的端点语义在 Aether 内部做成明确、可测试、可审计的一等路由语义,根治 `generativelanguage.googleapis.com` 与 `aiplatform.googleapis.com` 混用、批量 embedding 伪成功、provider 能力声明不完整等问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 速查结论
|
||||||
|
|
||||||
|
Aether 里同一个 `api_format` 只描述请求/响应数据形态,不等于实际 Google 后端产品面。
|
||||||
|
|
||||||
|
| Aether 语义 | 默认后端产品面 | 官方 host | 主要认证形态 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Google / Gemini Developer API | Gemini Developer API, 也就是 AI Studio 这条 Gemini API | `generativelanguage.googleapis.com` | API key | 默认 Gemini provider 应走这里 |
|
||||||
|
| Vertex AI | Vertex AI Gemini API | `aiplatform.googleapis.com` 或 `{region}-aiplatform.googleapis.com` | service account / Vertex API key | `provider_type = vertex_ai` 应走这里 |
|
||||||
|
|
||||||
|
端点动作必须按后端产品面区分:
|
||||||
|
|
||||||
|
| 能力 | Gemini Developer API | Vertex AI | Aether 处理原则 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
工程不变量:
|
||||||
|
|
||||||
|
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`。
|
||||||
|
4. 任何“不支持”的情况必须在调度/URL 构造阶段显式暴露为不可用,不能伪成功。
|
||||||
|
5. Provider 模板、runtime policy、URL builder、conversion policy、测试连接、live DB reconciliation 必须消费同一个语义模型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 官方资料依据
|
||||||
|
|
||||||
|
本节只记录影响工程设计的官方事实。实现前必须以这些来源为真源,而不是以旧代码行为为真源。
|
||||||
|
|
||||||
|
### Gemini Developer API / AI Studio
|
||||||
|
|
||||||
|
官方 Gemini API 文档把 Developer API 作为可直接用 API key 调用的产品面。其 REST API host 是 `generativelanguage.googleapis.com`,常见路径是 `/v1beta/models/{model}:...`。
|
||||||
|
|
||||||
|
关键资料:
|
||||||
|
|
||||||
|
- Gemini API reference: <https://ai.google.dev/api>
|
||||||
|
- Gemini API Generate Content: <https://ai.google.dev/api/generate-content>
|
||||||
|
- Gemini API Embeddings guide: <https://ai.google.dev/gemini-api/docs/embeddings>
|
||||||
|
- Gemini API embeddings reference: <https://ai.google.dev/api/embeddings>
|
||||||
|
- Gemini API migrate to cloud / Vertex AI: <https://ai.google.dev/gemini-api/docs/migrate-to-cloud>
|
||||||
|
|
||||||
|
工程含义:
|
||||||
|
|
||||||
|
- `generateContent` 与 `streamGenerateContent` 可以走 Developer API host。
|
||||||
|
- `embedContent` 是单条 embedding。
|
||||||
|
- `batchEmbedContents` 是 Developer API 的批量 embedding 方法;批量 body 形态是顶层 `requests[]`,每项包含 `model` 和 `content`。
|
||||||
|
- Developer API key 不应被拼进 path;Aether URL builder 应继续过滤或独立处理 `key` query,避免 query 重复或泄露。
|
||||||
|
|
||||||
|
### Vertex AI Gemini API
|
||||||
|
|
||||||
|
Vertex AI 的 Gemini API REST reference 使用 `aiplatform.googleapis.com` 或 region host,路径包含 GCP project 与 location。
|
||||||
|
|
||||||
|
关键资料:
|
||||||
|
|
||||||
|
- 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 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 service account 路径必须包含 project 和 location:
|
||||||
|
- `https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:{action}`
|
||||||
|
- 对 `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。
|
||||||
|
|
||||||
|
### Google Gen AI SDK 的后端切换语义
|
||||||
|
|
||||||
|
官方 SDK 同时支持 Gemini Developer API 与 Vertex AI,但二者需要显式选择后端。SDK 层面的 `vertexai=true` / `GOOGLE_GENAI_USE_VERTEXAI=true` 说明:这不是“同一 URL 自动兼容”的关系,而是同一 SDK 下的两个后端产品面。
|
||||||
|
|
||||||
|
关键资料:
|
||||||
|
|
||||||
|
- Google Gen AI SDK docs: <https://googleapis.github.io/python-genai/>
|
||||||
|
- Vertex AI SDK overview: <https://cloud.google.com/vertex-ai/generative-ai/docs/sdks/overview>
|
||||||
|
- Gemini API migrate to cloud / Vertex AI: <https://ai.google.dev/gemini-api/docs/migrate-to-cloud>
|
||||||
|
|
||||||
|
工程含义:
|
||||||
|
|
||||||
|
- Aether 也应把“选择 Gemini Developer API 还是 Vertex AI”作为显式路由语义,而不是让 URL builder 通过零散 host 字符串猜测。
|
||||||
|
- `api_format = gemini:generate_content` 与 `api_format = gemini:embedding` 只是数据格式。真正的后端产品面由 provider family / auth / endpoint host 决定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aether 当前相关链路
|
||||||
|
|
||||||
|
这一节说明在 Aether 内部,哪些对象共同决定一次 Gemini 请求实际打到哪里。
|
||||||
|
|
||||||
|
| 层 | 代表文件 | 当前职责 | 设计要求 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 请求格式转换 | `crates/aether-ai-formats/src/formats/...` | OpenAI / Gemini / Claude 等格式互转 | 只负责 body 形态,不决定 Google 后端产品面 |
|
||||||
|
| Provider 类型模板 | `crates/aether-provider-transport/src/provider_types.rs` | 固定 provider 默认 endpoint、runtime policy | Vertex 模板必须声明 generate + embedding 能力 |
|
||||||
|
| Runtime policy | `crates/aether-provider-transport/src/provider_types.rs` 和 provider policy | 判断 provider 是否本地可消费 | Vertex embedding 必须进入支持矩阵 |
|
||||||
|
| URL builder | `crates/aether-provider-transport/src/request_url/mod.rs` | 把 transport + mapped_model + api_format 转成 upstream URL | 必须按后端产品面构造 URL |
|
||||||
|
| Vertex helpers | `crates/aether-provider-transport/src/vertex/url.rs` | 构造 Vertex 特有 URL | 必须覆盖 generate / stream / embedding |
|
||||||
|
| Conversion policy | `crates/aether-provider-transport/src/conversion.rs` | 判定跨格式请求能否走某个 transport | OpenAI embedding -> Gemini embedding 在 Vertex 上必须可判定、可认证、可 URL |
|
||||||
|
| Gateway 测试连接 | `apps/aether-gateway/src/handlers/public/support/test_connection/route.rs` | 测试 provider endpoint 是否可用 | 不得用过低 token 或伪成功规则误判 Gemini 3 |
|
||||||
|
| Live provider reconciliation | gateway admin/provider 初始化与 DB | 把固定模板同步到 live DB | 新 endpoint 不应只存在源码里,必须进入 live provider/endpoints |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目标语义模型
|
||||||
|
|
||||||
|
新增或显式固化一个内部概念:`GeminiEndpointFamily`。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enum GeminiEndpointFamily {
|
||||||
|
DeveloperApi,
|
||||||
|
VertexAi,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该概念不一定必须以公开 enum 落地,但所有相关函数必须在行为上遵守同一判定:
|
||||||
|
|
||||||
|
| 判定输入 | 结果 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `provider_type == "vertex_ai"` | `VertexAi` | 固定 provider 主判据 |
|
||||||
|
| endpoint host 看起来是 `aiplatform.googleapis.com` 或 `{region}-aiplatform.googleapis.com` | `VertexAi` | 支持自定义 Vertex provider,但不可反客为主覆盖固定 provider |
|
||||||
|
| Vertex service account auth 可解析 | `VertexAi` | service account 是 Vertex 强语义 |
|
||||||
|
| Vertex API key query auth 可解析 | `VertexAi` | Vertex API key 仍是 Vertex 后端 |
|
||||||
|
| 普通 Google/Gemini provider + `generativelanguage.googleapis.com` | `DeveloperApi` | 默认 Gemini API |
|
||||||
|
|
||||||
|
禁止规则:
|
||||||
|
|
||||||
|
- 不得因为 `api_format` 是 `gemini:*` 就默认走 Vertex。
|
||||||
|
- 不得因为 Vertex 缺少某个 endpoint 就回退到 Developer API。
|
||||||
|
- 不得在 URL builder 里用“host 像谁就算谁”覆盖固定 provider 的 provider_type。
|
||||||
|
- 不得在 body converter 里偷偷决定 endpoint family;body converter 只能做数据形态转换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL 构造矩阵
|
||||||
|
|
||||||
|
### Developer API URL
|
||||||
|
|
||||||
|
| Aether api_format | stream | batch | URL 形态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `gemini:generate_content` | false | 不适用 | `/v1beta/models/{model}:generateContent` |
|
||||||
|
| `gemini:generate_content` | true | 不适用 | `/v1beta/models/{model}:streamGenerateContent?alt=sse` |
|
||||||
|
| `gemini:embedding` | false | false | `/v1beta/models/{model}:embedContent` |
|
||||||
|
| `gemini:embedding` | false | true | `/v1beta/models/{model}:batchEmbedContents` |
|
||||||
|
|
||||||
|
Developer API 的批量 embedding 支持顶层 `requests[]`。Aether 可以继续用 body 检测来决定单条还是批量 URL,但该检测只允许影响 Developer API URL。
|
||||||
|
|
||||||
|
### Vertex AI URL
|
||||||
|
|
||||||
|
| Aether api_format | stream | batch | URL 形态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
Vertex 单条 embedding 的模型由 URL path 承载,body 不得重复携带顶层 `model` 字段,否则会触发 Vertex `oneof field '_model' is already set` 一类错误。body 应只保留 `content` 与显式 embedding options。批量请求如果来自 OpenAI embedding 的数组输入,在没有官方 Vertex batch 端点前有两个可选工程策略:
|
||||||
|
|
||||||
|
1. 第一阶段 fail closed:返回明确 unsupported,不让它伪成功。
|
||||||
|
2. 第二阶段显式 fan-out:Aether 自己把数组拆成多条 Vertex `embedContent` 调用,再按 OpenAI embedding 响应格式合并。
|
||||||
|
|
||||||
|
本次先做第一阶段,因为它不会隐藏批量语义差异;后续如果实现 fan-out,必须有单独设计与负载控制,不得把 fan-out 塞进 URL builder。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 请求体转换边界
|
||||||
|
|
||||||
|
`aether-ai-formats` 中的 Gemini embedding converter 当前负责:
|
||||||
|
|
||||||
|
- 单条 input -> `embedContent` body
|
||||||
|
- 多条 input -> Developer API `batchEmbedContents` body
|
||||||
|
- `dimensions` -> `outputDimensionality`
|
||||||
|
- embedding task -> Gemini `taskType`
|
||||||
|
|
||||||
|
设计要求:
|
||||||
|
|
||||||
|
1. 该 converter 可以继续生成 Gemini batch body,但 transport 层必须知道这对 Vertex 不可直接消费。
|
||||||
|
2. 如果未来实现 Vertex fan-out,fan-out 应发生在 gateway execution 层,而不是让 `request_url` 或 body converter 假装一个 Vertex batch endpoint 存在。
|
||||||
|
3. 所有 taskType / outputDimensionality 必须保持显式传递;不得默认注入会改变语义的 task 或维度。
|
||||||
|
4. Developer API 单条 embedding body 可以保留 `model`;Vertex 单条 embedding 在 gateway transport 语义层必须删除顶层 `model`,因为 Vertex 模型已在 path 中指定。
|
||||||
|
|
||||||
|
### 格式转换矩阵
|
||||||
|
|
||||||
|
端点族和格式转换是两层语义:
|
||||||
|
|
||||||
|
- 端点族决定请求发往 `generativelanguage.googleapis.com` 还是 `aiplatform.googleapis.com`。
|
||||||
|
- 格式转换决定客户端传入的 body 如何变成 provider 所需 body,以及 provider response 如何变回客户端期望 body。
|
||||||
|
|
||||||
|
`gemini:generate_content` 在 Developer API 与 Vertex AI 上使用同一 Gemini generate-content body 形态,因此格式转换器不应区分这两个产品面。产品面差异只留给 URL/auth 层处理。
|
||||||
|
|
||||||
|
`gemini:embedding` 同样应使用同一 Gemini embedding body 形态,但 URL 层必须区分单条和批量能力。
|
||||||
|
|
||||||
|
| 客户端格式 | 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 |
|
||||||
|
| `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` |
|
||||||
|
| `openai:embedding` response | `gemini:embedding` response | 支持 | 支持于格式层 | OpenAI `data[]` -> Gemini single `embedding` 或 batch `embeddings[]` |
|
||||||
|
|
||||||
|
这张矩阵的关键点:
|
||||||
|
|
||||||
|
1. 格式层必须能双向理解 Gemini native embedding request/response 与 OpenAI embedding request/response。
|
||||||
|
2. Vertex 不支持 batch endpoint 是 transport/execution 能力限制,不是格式转换器不能表达 batch。
|
||||||
|
3. 一旦 provider family 是 Vertex,批量请求不能借格式转换之名回退到 Developer API。
|
||||||
|
4. 对 OpenAI embedding 单项数组,转换器必须生成 Gemini 单条 body,避免把“单条业务请求”误判成 Vertex batch。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider 能力声明与调度
|
||||||
|
|
||||||
|
Vertex provider 的固定模板必须包含:
|
||||||
|
|
||||||
|
- `gemini:generate_content`
|
||||||
|
- `gemini:embedding`
|
||||||
|
- `claude:messages`,如果当前上游 Vertex Claude 支持仍保留
|
||||||
|
|
||||||
|
Runtime policy 必须表达:
|
||||||
|
|
||||||
|
- Vertex 能本地消费 Gemini generate content。
|
||||||
|
- Vertex 能本地消费 Gemini single embedding。
|
||||||
|
- Vertex 不支持直接消费 Gemini batch embedding,除非未来实现 Aether fan-out execution。
|
||||||
|
- 全局模型名与 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。
|
||||||
|
- `request_pair_direct_auth` 对 Vertex API key 必须返回 `key` query auth;service account auth 由 OAuth refresh path 处理,不能伪造成普通 bearer key。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试设计
|
||||||
|
|
||||||
|
必须覆盖这些测试面:
|
||||||
|
|
||||||
|
1. Developer API generate URL:
|
||||||
|
- non-stream -> `generativelanguage.googleapis.com/...:generateContent`
|
||||||
|
- stream -> `...:streamGenerateContent?alt=sse`
|
||||||
|
2. Developer API embedding URL:
|
||||||
|
- 单条 body -> `...:embedContent`
|
||||||
|
- 多条 body -> `...:batchEmbedContents`
|
||||||
|
3. Vertex generate URL:
|
||||||
|
- 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`
|
||||||
|
- 不得生成 `generativelanguage.googleapis.com`
|
||||||
|
- 不得生成 `aiplatform.googleapis.com/...:batchEmbedContents`
|
||||||
|
6. Provider template:
|
||||||
|
- Vertex fixed template 包含 `gemini:embedding`
|
||||||
|
- 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 model,body 不含顶层 `model`
|
||||||
|
- Vertex batch embedding 不得通过 direct URL 构造检查
|
||||||
|
8. Gateway test connection:
|
||||||
|
- Gemini generate content 测试不能强制 `maxOutputTokens = 5`
|
||||||
|
- Gemini 3 / thinking 模型返回 HTTP 200 但无 visible content 时必须判失败,不能写成成功
|
||||||
|
|
||||||
|
测试断言必须检查具体 URL、具体 action、具体 unsupported 结果,不能只检查 `Some(url)` 或状态码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live 迁移与验证
|
||||||
|
|
||||||
|
上线后必须做四类验证:
|
||||||
|
|
||||||
|
1. 源码测试:
|
||||||
|
- `cargo test -p aether-provider-transport --lib`
|
||||||
|
- 必要时补 `cargo test -p aether-ai-formats --lib`
|
||||||
|
- 必要时补 gateway 相关 test
|
||||||
|
2. Live DB reconciliation:
|
||||||
|
- `vertex_ai` provider 的 endpoints 中必须出现 `gemini:embedding`
|
||||||
|
- Google/Gemini provider 的 embedding endpoint 仍指向 Developer API,不被 Vertex 改写
|
||||||
|
3. Live HTTP smoke:
|
||||||
|
- Developer API embedding 单条可用
|
||||||
|
- Developer API embedding 批量可用
|
||||||
|
- Vertex generate content 返回 visible content 才算成功
|
||||||
|
- Vertex single embedding 可用
|
||||||
|
- Vertex batch embedding 显式 unsupported,不能伪成功
|
||||||
|
4. 接入方地址核验:
|
||||||
|
- astrbot plugin ltm
|
||||||
|
- codex cli config
|
||||||
|
- 其它容器中引用 Aether 的配置
|
||||||
|
|
||||||
|
接入方默认应使用容器网络内稳定地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://aether-app:8084/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
只有在调用方不在 `edge-stack-aether-internal` 这类 Docker 内网、或需要从宿主机/外网访问时,才使用宿主机映射地址或域名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 明确不做的事
|
||||||
|
|
||||||
|
1. 不把 Vertex batch embedding 写成隐藏循环。隐藏 fan-out 会改变成本、延迟、断路器行为和重试语义,必须另开设计。
|
||||||
|
2. 不为了测试通过把 Vertex 请求降级到 Developer API。
|
||||||
|
3. 不为了让 HTTP 200 看起来成功而接受空 candidate / MAX_TOKENS 无 visible content。
|
||||||
|
4. 不改前端视觉定制、字体、品牌名、landing page 设计。
|
||||||
|
5. 不用旧 provider endpoint 继续承担新主链。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 施工顺序
|
||||||
|
|
||||||
|
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。
|
||||||
|
5. 移除测试连接中对 Gemini generate content 的过低 `maxOutputTokens` 硬编码,防止 Gemini 3 thinking 被预算挤空。
|
||||||
|
6. 跑 red/green 测试。
|
||||||
|
7. 部署 live。
|
||||||
|
8. 校验 live DB provider endpoints 与外部接入方地址。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续可选增强
|
||||||
|
|
||||||
|
如果 7 天 embedding 重算必须在 Vertex 上高吞吐完成,建议后续单独实现 `VertexEmbeddingFanoutExecutor`:
|
||||||
|
|
||||||
|
- 输入 OpenAI embedding 数组。
|
||||||
|
- 按配置分片,每片发单条或有限并发 Vertex `embedContent`。
|
||||||
|
- 合并为 OpenAI embedding response。
|
||||||
|
- 将每个子请求的失败、重试、成本、断路器状态独立记录。
|
||||||
|
- UI 上明确显示这是 Aether fan-out,不是 Google 官方 Vertex batch endpoint。
|
||||||
|
|
||||||
|
这项增强不能混入本次 endpoint 语义修复,否则会扩大风险面。
|
||||||
Reference in New Issue
Block a user