mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案
将 gateway 内部的 model-fetch、provider-transport、scheduler-core、 usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway 内部模块结构(state/router/cache/data/query 等);移除大量遗留模块 文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关 API 和组件。
This commit is contained in:
130
crates/aether-provider-transport/src/vertex/auth.rs
Normal file
130
crates/aether-provider-transport/src/vertex/auth.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
pub const VERTEX_API_KEY_QUERY_PARAM: &str = "key";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VertexApiKeyQueryAuth {
|
||||
pub name: &'static str,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub fn resolve_local_vertex_api_key_query_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<VertexApiKeyQueryAuth> {
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(super::PROVIDER_TYPE)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if transport.key.decrypted_auth_config.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !transport
|
||||
.key
|
||||
.auth_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("api_key")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let secret = transport.key.decrypted_api_key.trim();
|
||||
if secret.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(VertexApiKeyQueryAuth {
|
||||
name: VERTEX_API_KEY_QUERY_PARAM,
|
||||
value: secret.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
use super::{resolve_local_vertex_api_key_query_auth, VERTEX_API_KEY_QUERY_PARAM};
|
||||
|
||||
fn sample_transport() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "Vertex".to_string(),
|
||||
provider_type: "vertex_ai".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "gemini:chat".to_string(),
|
||||
api_family: Some("gemini".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://aiplatform.googleapis.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["gemini:chat".to_string()]),
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "vertex-secret".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_query_auth_for_vertex_api_key_subset() {
|
||||
let auth = resolve_local_vertex_api_key_query_auth(&sample_transport())
|
||||
.expect("vertex api key query auth should resolve");
|
||||
assert_eq!(auth.name, VERTEX_API_KEY_QUERY_PARAM);
|
||||
assert_eq!(auth.value, "vertex-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_api_key_transport() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.auth_type = "service_account".to_string();
|
||||
assert!(resolve_local_vertex_api_key_query_auth(&transport).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_vertex_auth_config_transport() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_auth_config = Some("{\"project_id\":\"demo-project\"}".to_string());
|
||||
assert!(resolve_local_vertex_api_key_query_auth(&transport).is_none());
|
||||
}
|
||||
}
|
||||
204
crates/aether-provider-transport/src/vertex/policy.rs
Normal file
204
crates/aether-provider-transport/src/vertex/policy.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
||||
use super::super::{
|
||||
body_rules_are_locally_supported, header_rules_are_locally_supported,
|
||||
resolve_transport_tls_profile, transport_proxy_is_locally_supported,
|
||||
};
|
||||
use super::auth::resolve_local_vertex_api_key_query_auth;
|
||||
|
||||
pub fn supports_local_vertex_api_key_gemini_transport(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
supports_local_vertex_api_key_same_format_transport(
|
||||
transport,
|
||||
&["gemini:chat", "gemini:cli"],
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn supports_local_vertex_api_key_gemini_transport_with_network(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
supports_local_vertex_api_key_same_format_transport(
|
||||
transport,
|
||||
&["gemini:chat", "gemini:cli"],
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn supports_local_vertex_api_key_imagen_transport(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
supports_local_vertex_api_key_same_format_transport(transport, &["gemini:chat"], false)
|
||||
}
|
||||
|
||||
pub fn supports_local_vertex_api_key_imagen_transport_with_network(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
supports_local_vertex_api_key_same_format_transport(transport, &["gemini:chat"], true)
|
||||
}
|
||||
|
||||
fn supports_local_vertex_api_key_same_format_transport(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
api_formats: &[&str],
|
||||
allow_network_passthrough: bool,
|
||||
) -> bool {
|
||||
if !transport.provider.is_active || !transport.endpoint.is_active || !transport.key.is_active {
|
||||
return false;
|
||||
}
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(super::PROVIDER_TYPE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let endpoint_api_format = transport.endpoint.api_format.trim();
|
||||
if !api_formats
|
||||
.iter()
|
||||
.any(|api_format| endpoint_api_format.eq_ignore_ascii_case(api_format))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref())
|
||||
|| !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if resolve_local_vertex_api_key_query_auth(transport).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let has_custom_path = transport
|
||||
.endpoint
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.is_some_and(|value: &str| !value.trim().is_empty());
|
||||
let has_tls_profile = resolve_transport_tls_profile(transport)
|
||||
.as_deref()
|
||||
.is_some_and(|value: &str| !value.trim().is_empty());
|
||||
if has_custom_path && !allow_network_passthrough {
|
||||
return false;
|
||||
}
|
||||
|
||||
if allow_network_passthrough {
|
||||
if !transport_proxy_is_locally_supported(transport) {
|
||||
return false;
|
||||
}
|
||||
if transport.key.fingerprint.is_some() && resolve_transport_tls_profile(transport).is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
} else if transport.provider.proxy.is_some()
|
||||
|| transport.endpoint.proxy.is_some()
|
||||
|| transport.key.proxy.is_some()
|
||||
|| has_tls_profile
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::super::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
use super::{
|
||||
supports_local_vertex_api_key_gemini_transport,
|
||||
supports_local_vertex_api_key_gemini_transport_with_network,
|
||||
};
|
||||
|
||||
fn sample_transport() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "Vertex".to_string(),
|
||||
provider_type: "vertex_ai".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "gemini:chat".to_string(),
|
||||
api_family: Some("gemini".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://aiplatform.googleapis.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["gemini:chat".to_string()]),
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "vertex-secret".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_vertex_api_key_same_format_subset() {
|
||||
assert!(supports_local_vertex_api_key_gemini_transport(
|
||||
&sample_transport()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_vertex_api_key_gemini_cli_subset() {
|
||||
let mut transport = sample_transport();
|
||||
transport.endpoint.api_format = "gemini:cli".to_string();
|
||||
assert!(supports_local_vertex_api_key_gemini_transport(&transport));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_vertex_service_account_subset() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.auth_type = "service_account".to_string();
|
||||
transport.key.decrypted_auth_config = Some("{\"project_id\":\"demo-project\"}".to_string());
|
||||
assert!(!supports_local_vertex_api_key_gemini_transport(&transport));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_network_passthrough_for_custom_path_with_local_proxy_support() {
|
||||
let mut transport = sample_transport();
|
||||
transport.endpoint.custom_path =
|
||||
Some("/v1/publishers/google/models/gemini-2.5-pro:generateContent".to_string());
|
||||
transport.key.proxy = Some(json!({"url":"http://proxy.example:8080"}));
|
||||
transport.key.fingerprint = Some(json!({"tls_profile":"chrome_136"}));
|
||||
assert!(!supports_local_vertex_api_key_gemini_transport(&transport));
|
||||
assert!(supports_local_vertex_api_key_gemini_transport_with_network(
|
||||
&transport
|
||||
));
|
||||
}
|
||||
}
|
||||
121
crates/aether-provider-transport/src/vertex/url.rs
Normal file
121
crates/aether-provider-transport/src/vertex/url.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use url::form_urlencoded;
|
||||
|
||||
use super::super::url::build_passthrough_path_url;
|
||||
|
||||
pub const VERTEX_API_KEY_BASE_URL: &str = "https://aiplatform.googleapis.com";
|
||||
|
||||
pub fn build_vertex_api_key_gemini_content_url(
|
||||
model: &str,
|
||||
stream: bool,
|
||||
api_key: &str,
|
||||
request_query: Option<&str>,
|
||||
) -> Option<String> {
|
||||
build_vertex_api_key_google_model_url(model, stream, api_key, request_query)
|
||||
}
|
||||
|
||||
pub fn build_vertex_api_key_imagen_content_url(
|
||||
model: &str,
|
||||
stream: bool,
|
||||
api_key: &str,
|
||||
request_query: Option<&str>,
|
||||
) -> Option<String> {
|
||||
build_vertex_api_key_google_model_url(model, stream, api_key, 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 {
|
||||
"streamGenerateContent"
|
||||
} else {
|
||||
"generateContent"
|
||||
};
|
||||
let path = format!("/v1/publishers/google/models/{trimmed_model}:{action}");
|
||||
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(), &[])
|
||||
}
|
||||
|
||||
fn build_vertex_api_key_query(
|
||||
api_key: &str,
|
||||
request_query: Option<&str>,
|
||||
stream: bool,
|
||||
) -> Option<String> {
|
||||
let mut merged = BTreeMap::new();
|
||||
merge_query_string(&mut merged, request_query);
|
||||
merged.remove("beta");
|
||||
merged.insert("key".to_string(), api_key.to_string());
|
||||
if stream {
|
||||
merged
|
||||
.entry("alt".to_string())
|
||||
.or_insert_with(|| "sse".to_string());
|
||||
}
|
||||
|
||||
let mut serializer = form_urlencoded::Serializer::new(String::new());
|
||||
for (key, value) in merged {
|
||||
serializer.append_pair(&key, &value);
|
||||
}
|
||||
let query = serializer.finish();
|
||||
if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(query)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_query_string(out: &mut BTreeMap<String, String>, query: Option<&str>) {
|
||||
let Some(query) = query.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
out.insert(key.into_owned(), value.into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_vertex_api_key_gemini_content_url, build_vertex_api_key_imagen_content_url};
|
||||
|
||||
#[test]
|
||||
fn builds_vertex_gemini_api_key_stream_url() {
|
||||
assert_eq!(
|
||||
build_vertex_api_key_gemini_content_url(
|
||||
"gemini-2.5-pro",
|
||||
true,
|
||||
"vertex-secret",
|
||||
Some("foo=bar&beta=v1")
|
||||
)
|
||||
.as_deref(),
|
||||
Some(
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-pro:streamGenerateContent?alt=sse&foo=bar&key=vertex-secret"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_vertex_imagen_api_key_sync_url() {
|
||||
assert_eq!(
|
||||
build_vertex_api_key_imagen_content_url(
|
||||
"imagen-3.0-generate-001",
|
||||
false,
|
||||
"vertex-secret",
|
||||
Some("view=full")
|
||||
)
|
||||
.as_deref(),
|
||||
Some(
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/imagen-3.0-generate-001:generateContent?key=vertex-secret&view=full"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user