feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层

- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate
- aether-data 扩展 repository 层:announcements、auth_modules、billing、
  candidate_selection、gemini_file_mappings、global_models、management_tokens、
  oauth_providers、proxy_nodes、quota、users、wallet 等模块
- aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/
  video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块
- 重构 executor decision 和 gateway state 为模块目录结构
- 新增 gateway router、frontdoor 路由层及对应测试
- Python 侧 API 路由重构,新增 compat/support 模块
- 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
fawney19
2026-03-31 19:19:04 +08:00
parent b5a0070023
commit ddf18fed9a
690 changed files with 235087 additions and 16301 deletions

View File

@@ -4,8 +4,9 @@ use std::sync::RwLock;
use async_trait::async_trait;
use super::types::{
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogKeyPage,
StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
};
use crate::DataLayerError;
@@ -45,6 +46,25 @@ impl InMemoryProviderCatalogReadRepository {
#[async_trait]
impl ProviderCatalogReadRepository for InMemoryProviderCatalogReadRepository {
async fn list_providers(
&self,
active_only: bool,
) -> Result<Vec<StoredProviderCatalogProvider>, DataLayerError> {
let index = self.index.read().expect("provider catalog repository lock");
let mut providers = index
.providers
.values()
.filter(|provider| !active_only || provider.is_active)
.cloned()
.collect::<Vec<_>>();
providers.sort_by(|left, right| {
left.provider_priority
.cmp(&right.provider_priority)
.then(left.name.cmp(&right.name))
});
Ok(providers)
}
async fn list_providers_by_ids(
&self,
provider_ids: &[String],
@@ -67,6 +87,30 @@ impl ProviderCatalogReadRepository for InMemoryProviderCatalogReadRepository {
.collect())
}
async fn list_endpoints_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogEndpoint>, DataLayerError> {
let index = self.index.read().expect("provider catalog repository lock");
let mut endpoints = index
.endpoints
.values()
.filter(|endpoint| {
provider_ids
.iter()
.any(|provider_id| provider_id == &endpoint.provider_id)
})
.cloned()
.collect::<Vec<_>>();
endpoints.sort_by(|left, right| {
left.provider_id
.cmp(&right.provider_id)
.then(left.api_format.cmp(&right.api_format))
.then(left.id.cmp(&right.id))
});
Ok(endpoints)
}
async fn list_keys_by_ids(
&self,
key_ids: &[String],
@@ -77,14 +121,302 @@ impl ProviderCatalogReadRepository for InMemoryProviderCatalogReadRepository {
.filter_map(|id| index.keys.get(id).cloned())
.collect())
}
async fn list_keys_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
let index = self.index.read().expect("provider catalog repository lock");
let mut keys = index
.keys
.values()
.filter(|key| {
provider_ids
.iter()
.any(|provider_id| provider_id == &key.provider_id)
})
.cloned()
.collect::<Vec<_>>();
keys.sort_by(|left, right| {
left.provider_id
.cmp(&right.provider_id)
.then(left.name.cmp(&right.name))
.then(left.id.cmp(&right.id))
});
Ok(keys)
}
async fn list_keys_page(
&self,
query: &ProviderCatalogKeyListQuery,
) -> Result<StoredProviderCatalogKeyPage, DataLayerError> {
let index = self.index.read().expect("provider catalog repository lock");
let mut keys = index
.keys
.values()
.filter(|key| key.provider_id == query.provider_id)
.filter(|key| {
query.search.as_ref().is_none_or(|keyword| {
let keyword = keyword.trim().to_ascii_lowercase();
keyword.is_empty()
|| key.name.to_ascii_lowercase().contains(&keyword)
|| key.id.to_ascii_lowercase().contains(&keyword)
})
})
.filter(|key| {
query
.is_active
.is_none_or(|is_active| key.is_active == is_active)
})
.cloned()
.collect::<Vec<_>>();
keys.sort_by(|left, right| {
left.internal_priority
.cmp(&right.internal_priority)
.then(left.name.cmp(&right.name))
.then(left.id.cmp(&right.id))
});
let total = keys.len();
let items = keys
.into_iter()
.skip(query.offset)
.take(query.limit)
.collect();
Ok(StoredProviderCatalogKeyPage { items, total })
}
async fn list_key_stats_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKeyStats>, DataLayerError> {
let index = self.index.read().expect("provider catalog repository lock");
let mut stats = provider_ids
.iter()
.map(|provider_id| {
let total_keys = index
.keys
.values()
.filter(|key| &key.provider_id == provider_id)
.count() as i64;
let active_keys = index
.keys
.values()
.filter(|key| &key.provider_id == provider_id && key.is_active)
.count() as i64;
StoredProviderCatalogKeyStats::new(provider_id.clone(), total_keys, active_keys)
})
.collect::<Result<Vec<_>, _>>()?;
stats.retain(|item| item.total_keys > 0);
Ok(stats)
}
}
#[async_trait]
impl ProviderCatalogWriteRepository for InMemoryProviderCatalogReadRepository {
async fn create_provider(
&self,
provider: &StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
) -> Result<StoredProviderCatalogProvider, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
if let Some(target_priority) = shift_existing_priorities_from {
for existing in index.providers.values_mut() {
if existing.provider_priority >= target_priority {
existing.provider_priority += 1;
}
}
}
index
.providers
.insert(provider.id.clone(), provider.clone());
Ok(provider.clone())
}
async fn update_provider(
&self,
provider: &StoredProviderCatalogProvider,
) -> Result<StoredProviderCatalogProvider, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
let Some(stored) = index.providers.get_mut(&provider.id) else {
return Err(DataLayerError::UnexpectedValue(format!(
"provider catalog provider {} not found",
provider.id
)));
};
*stored = provider.clone();
Ok(stored.clone())
}
async fn delete_provider(&self, provider_id: &str) -> Result<bool, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
Ok(index.providers.remove(provider_id).is_some())
}
async fn cleanup_deleted_provider_refs(
&self,
_provider_id: &str,
_endpoint_ids: &[String],
_key_ids: &[String],
) -> Result<(), DataLayerError> {
Ok(())
}
async fn create_endpoint(
&self,
endpoint: &StoredProviderCatalogEndpoint,
) -> Result<StoredProviderCatalogEndpoint, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
index
.endpoints
.insert(endpoint.id.clone(), endpoint.clone());
Ok(endpoint.clone())
}
async fn update_endpoint(
&self,
endpoint: &StoredProviderCatalogEndpoint,
) -> Result<StoredProviderCatalogEndpoint, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
let Some(stored) = index.endpoints.get_mut(&endpoint.id) else {
return Err(DataLayerError::UnexpectedValue(format!(
"provider catalog endpoint {} not found",
endpoint.id
)));
};
*stored = endpoint.clone();
Ok(stored.clone())
}
async fn delete_endpoint(&self, endpoint_id: &str) -> Result<bool, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
Ok(index.endpoints.remove(endpoint_id).is_some())
}
async fn create_key(
&self,
key: &StoredProviderCatalogKey,
) -> Result<StoredProviderCatalogKey, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
index.keys.insert(key.id.clone(), key.clone());
Ok(key.clone())
}
async fn update_key(
&self,
key: &StoredProviderCatalogKey,
) -> Result<StoredProviderCatalogKey, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
let Some(stored) = index.keys.get_mut(&key.id) else {
return Err(DataLayerError::UnexpectedValue(format!(
"provider catalog key {} not found",
key.id
)));
};
*stored = key.clone();
Ok(stored.clone())
}
async fn delete_key(&self, key_id: &str) -> Result<bool, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
Ok(index.keys.remove(key_id).is_some())
}
async fn clear_key_oauth_invalid_marker(&self, key_id: &str) -> Result<bool, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
let Some(key) = index.keys.get_mut(key_id) else {
return Ok(false);
};
key.oauth_invalid_at_unix_secs = None;
key.oauth_invalid_reason = None;
Ok(true)
}
async fn update_key_oauth_credentials(
&self,
key_id: &str,
encrypted_api_key: &str,
encrypted_auth_config: Option<&str>,
expires_at_unix_secs: Option<u64>,
) -> Result<bool, DataLayerError> {
if encrypted_api_key.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"provider catalog oauth api_key is empty".to_string(),
));
}
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
let Some(key) = index.keys.get_mut(key_id) else {
return Ok(false);
};
key.encrypted_api_key = encrypted_api_key.to_string();
key.encrypted_auth_config = encrypted_auth_config.map(ToOwned::to_owned);
key.expires_at_unix_secs = expires_at_unix_secs;
Ok(true)
}
async fn update_key_health_state(
&self,
key_id: &str,
is_active: bool,
health_by_format: Option<&serde_json::Value>,
circuit_breaker_by_format: Option<&serde_json::Value>,
) -> Result<bool, DataLayerError> {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
let Some(key) = index.keys.get_mut(key_id) else {
return Ok(false);
};
key.is_active = is_active;
key.health_by_format = health_by_format.cloned();
key.circuit_breaker_by_format = circuit_breaker_by_format.cloned();
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::InMemoryProviderCatalogReadRepository;
use crate::repository::provider_catalog::{
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
fn sample_provider(id: &str) -> StoredProviderCatalogProvider {
@@ -107,6 +439,7 @@ mod tests {
true,
)
.expect("endpoint should build")
.with_health_score(0.9)
}
fn sample_key(id: &str, provider_id: &str) -> StoredProviderCatalogKey {
@@ -154,4 +487,286 @@ mod tests {
1
);
}
#[tokio::test]
async fn lists_active_providers_in_priority_order() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-2").with_routing_fields(20),
sample_provider("provider-1").with_routing_fields(10),
sample_provider("provider-3")
.with_routing_fields(5)
.with_transport_fields(false, false, false, None, None, None, None, None, None),
],
vec![],
vec![],
);
let providers = repository
.list_providers(true)
.await
.expect("providers should list");
assert_eq!(
providers
.iter()
.map(|provider| provider.id.as_str())
.collect::<Vec<_>>(),
vec!["provider-1", "provider-2"]
);
}
#[tokio::test]
async fn updates_oauth_credentials_for_existing_key() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1")],
vec![sample_endpoint("endpoint-1", "provider-1")],
vec![sample_key("key-1", "provider-1")
.with_transport_fields(
None,
"ciphertext-placeholder".to_string(),
Some("ciphertext-auth-1".to_string()),
None,
None,
None,
None,
None,
None,
)
.expect("key transport should build")],
);
assert!(repository
.update_key_oauth_credentials(
"key-1",
"ciphertext-updated-token",
Some("ciphertext-auth-2"),
Some(4_102_444_800),
)
.await
.expect("update should succeed"));
let stored = repository
.list_keys_by_ids(&["key-1".to_string()])
.await
.expect("keys should read");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].encrypted_api_key, "ciphertext-updated-token");
assert_eq!(
stored[0].encrypted_auth_config.as_deref(),
Some("ciphertext-auth-2")
);
assert_eq!(stored[0].expires_at_unix_secs, Some(4_102_444_800));
}
#[tokio::test]
async fn paginates_provider_keys_with_search_and_active_filter() {
let mut alpha = sample_key("key-1", "provider-1");
alpha.name = "alpha".to_string();
alpha.internal_priority = 20;
let mut beta = sample_key("key-2", "provider-1");
beta.name = "beta".to_string();
beta.internal_priority = 10;
let mut gamma = sample_key("key-3", "provider-1");
gamma.name = "gamma".to_string();
gamma.internal_priority = 30;
gamma.is_active = false;
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1"), sample_provider("provider-2")],
vec![],
vec![alpha, beta, gamma, sample_key("key-4", "provider-2")],
);
let page = repository
.list_keys_page(&ProviderCatalogKeyListQuery {
provider_id: "provider-1".to_string(),
search: Some("a".to_string()),
is_active: Some(true),
offset: 0,
limit: 10,
})
.await
.expect("keys should page");
assert_eq!(page.total, 2);
assert_eq!(page.items.len(), 2);
assert_eq!(
page.items
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>(),
vec!["beta", "alpha"]
);
}
#[tokio::test]
async fn summarizes_provider_key_stats() {
let mut inactive = sample_key("key-2", "provider-1");
inactive.is_active = false;
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1"), sample_provider("provider-2")],
vec![],
vec![
sample_key("key-1", "provider-1"),
inactive,
sample_key("key-3", "provider-2"),
],
);
let stats = repository
.list_key_stats_by_provider_ids(&["provider-1".to_string(), "provider-2".to_string()])
.await
.expect("stats should list");
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].provider_id, "provider-1");
assert_eq!(stats[0].total_keys, 2);
assert_eq!(stats[0].active_keys, 1);
assert_eq!(stats[1].provider_id, "provider-2");
assert_eq!(stats[1].total_keys, 1);
assert_eq!(stats[1].active_keys, 1);
}
#[tokio::test]
async fn creates_key() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1")],
vec![],
vec![],
);
let key = sample_key("key-1", "provider-1");
let created = repository
.create_key(&key)
.await
.expect("key should create");
assert_eq!(created.id, "key-1");
let stored = repository
.list_keys_by_ids(&["key-1".to_string()])
.await
.expect("keys should read");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].provider_id, "provider-1");
}
#[tokio::test]
async fn creates_endpoint() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1")],
vec![],
vec![],
);
let endpoint = sample_endpoint("endpoint-1", "provider-1");
let created = repository
.create_endpoint(&endpoint)
.await
.expect("endpoint should create");
assert_eq!(created.id, "endpoint-1");
let stored = repository
.list_endpoints_by_ids(&["endpoint-1".to_string()])
.await
.expect("endpoints should read");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].provider_id, "provider-1");
}
#[tokio::test]
async fn updates_key() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1")],
vec![],
vec![sample_key("key-1", "provider-1")],
);
let mut updated = sample_key("key-1", "provider-1");
updated.name = "updated".to_string();
updated.internal_priority = 7;
let stored = repository
.update_key(&updated)
.await
.expect("key should update");
assert_eq!(stored.name, "updated");
assert_eq!(stored.internal_priority, 7);
let reloaded = repository
.list_keys_by_ids(&["key-1".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded[0].name, "updated");
assert_eq!(reloaded[0].internal_priority, 7);
}
#[tokio::test]
async fn updates_endpoint() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1")],
vec![sample_endpoint("endpoint-1", "provider-1")],
vec![],
);
let updated = sample_endpoint("endpoint-1", "provider-1")
.with_transport_fields(
"https://updated.example".to_string(),
None,
None,
Some(5),
Some("/v1/chat/completions".to_string()),
Some(serde_json::json!({"foo":"bar"})),
None,
None,
)
.expect("endpoint transport should build");
let stored = repository
.update_endpoint(&updated)
.await
.expect("endpoint should update");
assert_eq!(stored.base_url, "https://updated.example");
assert_eq!(stored.max_retries, Some(5));
let reloaded = repository
.list_endpoints_by_ids(&["endpoint-1".to_string()])
.await
.expect("endpoints should read");
assert_eq!(reloaded[0].base_url, "https://updated.example");
assert_eq!(reloaded[0].max_retries, Some(5));
}
#[tokio::test]
async fn deletes_key() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1")],
vec![],
vec![sample_key("key-1", "provider-1")],
);
assert!(repository
.delete_key("key-1")
.await
.expect("delete should succeed"));
let reloaded = repository
.list_keys_by_ids(&["key-1".to_string()])
.await
.expect("keys should read");
assert!(reloaded.is_empty());
}
#[tokio::test]
async fn deletes_endpoint() {
let repository = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1")],
vec![sample_endpoint("endpoint-1", "provider-1")],
vec![],
);
assert!(repository
.delete_endpoint("endpoint-1")
.await
.expect("delete should succeed"));
let reloaded = repository
.list_endpoints_by_ids(&["endpoint-1".to_string()])
.await
.expect("endpoints should read");
assert!(reloaded.is_empty());
}
}

View File

@@ -5,6 +5,7 @@ mod types;
pub use memory::InMemoryProviderCatalogReadRepository;
pub use sql::SqlxProviderCatalogReadRepository;
pub use types::{
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogKeyPage,
StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,30 @@
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredProviderCatalogProvider {
pub id: String,
pub name: String,
pub description: Option<String>,
pub website: Option<String>,
pub provider_type: String,
pub billing_type: Option<String>,
pub monthly_quota_usd: Option<f64>,
pub monthly_used_usd: Option<f64>,
pub quota_reset_day: Option<u64>,
pub quota_last_reset_at_unix_secs: Option<u64>,
pub quota_expires_at_unix_secs: Option<u64>,
pub provider_priority: i32,
pub is_active: bool,
pub keep_priority_on_conversion: bool,
pub enable_format_conversion: bool,
pub concurrent_limit: Option<i32>,
pub max_retries: Option<i32>,
pub proxy: Option<serde_json::Value>,
pub request_timeout_secs: Option<f64>,
pub stream_first_byte_timeout_secs: Option<f64>,
pub config: Option<serde_json::Value>,
pub created_at_unix_secs: Option<u64>,
pub updated_at_unix_secs: Option<u64>,
}
impl StoredProviderCatalogProvider {
@@ -29,13 +48,96 @@ impl StoredProviderCatalogProvider {
Ok(Self {
id,
name,
description: None,
website,
provider_type,
billing_type: None,
monthly_quota_usd: None,
monthly_used_usd: None,
quota_reset_day: None,
quota_last_reset_at_unix_secs: None,
quota_expires_at_unix_secs: None,
provider_priority: 0,
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,
created_at_unix_secs: None,
updated_at_unix_secs: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_transport_fields(
mut self,
is_active: bool,
keep_priority_on_conversion: bool,
enable_format_conversion: bool,
concurrent_limit: Option<i32>,
max_retries: Option<i32>,
proxy: Option<serde_json::Value>,
request_timeout_secs: Option<f64>,
stream_first_byte_timeout_secs: Option<f64>,
config: Option<serde_json::Value>,
) -> Self {
self.is_active = is_active;
self.keep_priority_on_conversion = keep_priority_on_conversion;
self.enable_format_conversion = enable_format_conversion;
self.concurrent_limit = concurrent_limit;
self.max_retries = max_retries;
self.proxy = proxy;
self.request_timeout_secs = request_timeout_secs;
self.stream_first_byte_timeout_secs = stream_first_byte_timeout_secs;
self.config = config;
self
}
pub fn with_description(mut self, description: Option<String>) -> Self {
self.description = description;
self
}
#[allow(clippy::too_many_arguments)]
pub fn with_billing_fields(
mut self,
billing_type: Option<String>,
monthly_quota_usd: Option<f64>,
monthly_used_usd: Option<f64>,
quota_reset_day: Option<u64>,
quota_last_reset_at_unix_secs: Option<u64>,
quota_expires_at_unix_secs: Option<u64>,
) -> Self {
self.billing_type = billing_type;
self.monthly_quota_usd = monthly_quota_usd;
self.monthly_used_usd = monthly_used_usd;
self.quota_reset_day = quota_reset_day;
self.quota_last_reset_at_unix_secs = quota_last_reset_at_unix_secs;
self.quota_expires_at_unix_secs = quota_expires_at_unix_secs;
self
}
pub fn with_routing_fields(mut self, provider_priority: i32) -> Self {
self.provider_priority = provider_priority;
self
}
pub fn with_timestamps(
mut self,
created_at_unix_secs: Option<u64>,
updated_at_unix_secs: Option<u64>,
) -> Self {
self.created_at_unix_secs = created_at_unix_secs;
self.updated_at_unix_secs = updated_at_unix_secs;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredProviderCatalogEndpoint {
pub id: String,
pub provider_id: String,
@@ -43,6 +145,17 @@ pub struct StoredProviderCatalogEndpoint {
pub api_family: Option<String>,
pub endpoint_kind: Option<String>,
pub is_active: bool,
pub health_score: f64,
pub base_url: String,
pub header_rules: Option<serde_json::Value>,
pub body_rules: Option<serde_json::Value>,
pub max_retries: Option<i32>,
pub custom_path: Option<String>,
pub config: Option<serde_json::Value>,
pub format_acceptance_config: Option<serde_json::Value>,
pub proxy: Option<serde_json::Value>,
pub created_at_unix_secs: Option<u64>,
pub updated_at_unix_secs: Option<u64>,
}
impl StoredProviderCatalogEndpoint {
@@ -67,11 +180,66 @@ impl StoredProviderCatalogEndpoint {
api_family,
endpoint_kind,
is_active,
health_score: 1.0,
base_url: String::new(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
created_at_unix_secs: None,
updated_at_unix_secs: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_transport_fields(
mut self,
base_url: String,
header_rules: Option<serde_json::Value>,
body_rules: Option<serde_json::Value>,
max_retries: Option<i32>,
custom_path: Option<String>,
config: Option<serde_json::Value>,
format_acceptance_config: Option<serde_json::Value>,
proxy: Option<serde_json::Value>,
) -> Result<Self, crate::DataLayerError> {
if base_url.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"provider_endpoints.base_url is empty".to_string(),
));
}
self.base_url = base_url;
self.header_rules = header_rules;
self.body_rules = body_rules;
self.max_retries = max_retries;
self.custom_path = custom_path;
self.config = config;
self.format_acceptance_config = format_acceptance_config;
self.proxy = proxy;
Ok(self)
}
pub fn with_health_score(mut self, health_score: f64) -> Self {
self.health_score = health_score;
self
}
pub fn with_timestamps(
mut self,
created_at_unix_secs: Option<u64>,
updated_at_unix_secs: Option<u64>,
) -> Self {
self.created_at_unix_secs = created_at_unix_secs;
self.updated_at_unix_secs = updated_at_unix_secs;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredProviderCatalogKey {
pub id: String,
pub provider_id: String,
@@ -79,6 +247,47 @@ pub struct StoredProviderCatalogKey {
pub auth_type: String,
pub capabilities: Option<serde_json::Value>,
pub is_active: bool,
pub api_formats: Option<serde_json::Value>,
pub encrypted_api_key: String,
pub encrypted_auth_config: Option<String>,
pub note: Option<String>,
pub internal_priority: i32,
pub rate_multipliers: Option<serde_json::Value>,
pub global_priority_by_format: Option<serde_json::Value>,
pub allowed_models: Option<serde_json::Value>,
pub expires_at_unix_secs: Option<u64>,
pub cache_ttl_minutes: i32,
pub max_probe_interval_minutes: i32,
pub proxy: Option<serde_json::Value>,
pub fingerprint: Option<serde_json::Value>,
pub rpm_limit: Option<u32>,
pub learned_rpm_limit: Option<u32>,
pub concurrent_429_count: Option<u32>,
pub rpm_429_count: Option<u32>,
pub last_429_at_unix_secs: Option<u64>,
pub last_429_type: Option<String>,
pub adjustment_history: Option<serde_json::Value>,
pub utilization_samples: Option<serde_json::Value>,
pub last_probe_increase_at_unix_secs: Option<u64>,
pub request_count: Option<u32>,
pub success_count: Option<u32>,
pub error_count: Option<u32>,
pub total_response_time_ms: Option<u32>,
pub last_used_at_unix_secs: Option<u64>,
pub auto_fetch_models: bool,
pub last_models_fetch_at_unix_secs: Option<u64>,
pub last_models_fetch_error: Option<String>,
pub locked_models: Option<serde_json::Value>,
pub model_include_patterns: Option<serde_json::Value>,
pub model_exclude_patterns: Option<serde_json::Value>,
pub upstream_metadata: Option<serde_json::Value>,
pub oauth_invalid_at_unix_secs: Option<u64>,
pub oauth_invalid_reason: Option<String>,
pub status_snapshot: Option<serde_json::Value>,
pub created_at_unix_secs: Option<u64>,
pub updated_at_unix_secs: Option<u64>,
pub health_by_format: Option<serde_json::Value>,
pub circuit_breaker_by_format: Option<serde_json::Value>,
}
impl StoredProviderCatalogKey {
@@ -108,12 +317,179 @@ impl StoredProviderCatalogKey {
auth_type,
capabilities,
is_active,
api_formats: None,
encrypted_api_key: String::new(),
encrypted_auth_config: None,
note: None,
internal_priority: 50,
rate_multipliers: None,
global_priority_by_format: None,
allowed_models: None,
expires_at_unix_secs: None,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
proxy: None,
fingerprint: None,
rpm_limit: None,
learned_rpm_limit: None,
concurrent_429_count: None,
rpm_429_count: None,
last_429_at_unix_secs: None,
last_429_type: None,
adjustment_history: None,
utilization_samples: None,
last_probe_increase_at_unix_secs: None,
request_count: None,
success_count: None,
error_count: None,
total_response_time_ms: None,
last_used_at_unix_secs: None,
auto_fetch_models: false,
last_models_fetch_at_unix_secs: None,
last_models_fetch_error: None,
locked_models: None,
model_include_patterns: None,
model_exclude_patterns: None,
upstream_metadata: None,
oauth_invalid_at_unix_secs: None,
oauth_invalid_reason: None,
status_snapshot: None,
created_at_unix_secs: None,
updated_at_unix_secs: None,
health_by_format: None,
circuit_breaker_by_format: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_transport_fields(
mut self,
api_formats: Option<serde_json::Value>,
encrypted_api_key: String,
encrypted_auth_config: Option<String>,
rate_multipliers: Option<serde_json::Value>,
global_priority_by_format: Option<serde_json::Value>,
allowed_models: Option<serde_json::Value>,
expires_at_unix_secs: Option<u64>,
proxy: Option<serde_json::Value>,
fingerprint: Option<serde_json::Value>,
) -> Result<Self, crate::DataLayerError> {
if encrypted_api_key.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"provider_api_keys.api_key is empty".to_string(),
));
}
self.api_formats = api_formats;
self.encrypted_api_key = encrypted_api_key;
self.encrypted_auth_config = encrypted_auth_config;
self.rate_multipliers = rate_multipliers;
self.global_priority_by_format = global_priority_by_format;
self.allowed_models = allowed_models;
self.expires_at_unix_secs = expires_at_unix_secs;
self.proxy = proxy;
self.fingerprint = fingerprint;
Ok(self)
}
#[allow(clippy::too_many_arguments)]
pub fn with_rate_limit_fields(
mut self,
rpm_limit: Option<u32>,
learned_rpm_limit: Option<u32>,
concurrent_429_count: Option<u32>,
rpm_429_count: Option<u32>,
last_429_at_unix_secs: Option<u64>,
adjustment_history: Option<serde_json::Value>,
request_count: Option<u32>,
success_count: Option<u32>,
) -> Self {
self.rpm_limit = rpm_limit;
self.learned_rpm_limit = learned_rpm_limit;
self.concurrent_429_count = concurrent_429_count;
self.rpm_429_count = rpm_429_count;
self.last_429_at_unix_secs = last_429_at_unix_secs;
self.adjustment_history = adjustment_history;
self.request_count = request_count;
self.success_count = success_count;
self
}
pub fn with_usage_fields(
mut self,
error_count: Option<u32>,
total_response_time_ms: Option<u32>,
) -> Self {
self.error_count = error_count;
self.total_response_time_ms = total_response_time_ms;
self
}
pub fn with_health_fields(
mut self,
health_by_format: Option<serde_json::Value>,
circuit_breaker_by_format: Option<serde_json::Value>,
) -> Self {
self.health_by_format = health_by_format;
self.circuit_breaker_by_format = circuit_breaker_by_format;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProviderCatalogKeyListQuery {
pub provider_id: String,
pub search: Option<String>,
pub is_active: Option<bool>,
pub offset: usize,
pub limit: usize,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredProviderCatalogKeyPage {
pub items: Vec<StoredProviderCatalogKey>,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredProviderCatalogKeyStats {
pub provider_id: String,
pub total_keys: u64,
pub active_keys: u64,
}
impl StoredProviderCatalogKeyStats {
pub fn new(
provider_id: String,
total_keys: i64,
active_keys: i64,
) -> Result<Self, crate::DataLayerError> {
if provider_id.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"provider key stats provider_id is empty".to_string(),
));
}
if total_keys < 0 || active_keys < 0 {
return Err(crate::DataLayerError::UnexpectedValue(
"provider key stats count is negative".to_string(),
));
}
Ok(Self {
provider_id,
total_keys: total_keys as u64,
active_keys: active_keys as u64,
})
}
}
#[async_trait]
pub trait ProviderCatalogReadRepository: Send + Sync {
async fn list_providers(
&self,
active_only: bool,
) -> Result<Vec<StoredProviderCatalogProvider>, crate::DataLayerError>;
async fn list_providers_by_ids(
&self,
provider_ids: &[String],
@@ -124,10 +500,98 @@ pub trait ProviderCatalogReadRepository: Send + Sync {
endpoint_ids: &[String],
) -> Result<Vec<StoredProviderCatalogEndpoint>, crate::DataLayerError>;
async fn list_endpoints_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogEndpoint>, crate::DataLayerError>;
async fn list_keys_by_ids(
&self,
key_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, crate::DataLayerError>;
async fn list_keys_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, crate::DataLayerError>;
async fn list_keys_page(
&self,
query: &ProviderCatalogKeyListQuery,
) -> Result<StoredProviderCatalogKeyPage, crate::DataLayerError>;
async fn list_key_stats_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKeyStats>, crate::DataLayerError>;
}
#[async_trait]
pub trait ProviderCatalogWriteRepository: Send + Sync {
async fn create_provider(
&self,
provider: &StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
) -> Result<StoredProviderCatalogProvider, crate::DataLayerError>;
async fn update_provider(
&self,
provider: &StoredProviderCatalogProvider,
) -> Result<StoredProviderCatalogProvider, crate::DataLayerError>;
async fn delete_provider(&self, provider_id: &str) -> Result<bool, crate::DataLayerError>;
async fn cleanup_deleted_provider_refs(
&self,
provider_id: &str,
endpoint_ids: &[String],
key_ids: &[String],
) -> Result<(), crate::DataLayerError>;
async fn create_endpoint(
&self,
endpoint: &StoredProviderCatalogEndpoint,
) -> Result<StoredProviderCatalogEndpoint, crate::DataLayerError>;
async fn update_endpoint(
&self,
endpoint: &StoredProviderCatalogEndpoint,
) -> Result<StoredProviderCatalogEndpoint, crate::DataLayerError>;
async fn delete_endpoint(&self, endpoint_id: &str) -> Result<bool, crate::DataLayerError>;
async fn create_key(
&self,
key: &StoredProviderCatalogKey,
) -> Result<StoredProviderCatalogKey, crate::DataLayerError>;
async fn update_key(
&self,
key: &StoredProviderCatalogKey,
) -> Result<StoredProviderCatalogKey, crate::DataLayerError>;
async fn delete_key(&self, key_id: &str) -> Result<bool, crate::DataLayerError>;
async fn clear_key_oauth_invalid_marker(
&self,
key_id: &str,
) -> Result<bool, crate::DataLayerError>;
async fn update_key_oauth_credentials(
&self,
key_id: &str,
encrypted_api_key: &str,
encrypted_auth_config: Option<&str>,
expires_at_unix_secs: Option<u64>,
) -> Result<bool, crate::DataLayerError>;
async fn update_key_health_state(
&self,
key_id: &str,
is_active: bool,
health_by_format: Option<&serde_json::Value>,
circuit_breaker_by_format: Option<&serde_json::Value>,
) -> Result<bool, crate::DataLayerError>;
}
#[cfg(test)]
@@ -160,6 +624,22 @@ mod tests {
.is_err());
}
#[test]
fn rejects_empty_endpoint_base_url() {
let endpoint = StoredProviderCatalogEndpoint::new(
"endpoint-1".to_string(),
"provider-1".to_string(),
"openai:chat".to_string(),
None,
None,
true,
)
.expect("endpoint should build");
assert!(endpoint
.with_transport_fields("".to_string(), None, None, None, None, None, None, None,)
.is_err());
}
#[test]
fn rejects_empty_key_auth_type() {
assert!(StoredProviderCatalogKey::new(
@@ -172,4 +652,87 @@ mod tests {
)
.is_err());
}
#[test]
fn rejects_empty_encrypted_api_key() {
let key = StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
"default".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build");
assert!(key
.with_transport_fields(
None,
"".to_string(),
None,
None,
None,
None,
None,
None,
None
)
.is_err());
}
#[test]
fn stores_key_rate_limit_fields() {
let key = StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
"default".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_rate_limit_fields(
Some(100),
Some(80),
Some(2),
Some(3),
Some(1_700_000_000),
Some(serde_json::json!([{"new_limit": 80}])),
Some(120),
Some(110),
);
assert_eq!(key.rpm_limit, Some(100));
assert_eq!(key.learned_rpm_limit, Some(80));
assert_eq!(key.concurrent_429_count, Some(2));
assert_eq!(key.rpm_429_count, Some(3));
assert_eq!(key.last_429_at_unix_secs, Some(1_700_000_000));
assert_eq!(key.request_count, Some(120));
assert_eq!(key.success_count, Some(110));
}
#[test]
fn stores_key_health_fields() {
let key = StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
"default".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_health_fields(
Some(serde_json::json!({"openai:chat": {"health_score": 0.4}})),
Some(serde_json::json!({"openai:chat": {"open": true}})),
);
assert_eq!(
key.health_by_format,
Some(serde_json::json!({"openai:chat": {"health_score": 0.4}}))
);
assert_eq!(
key.circuit_breaker_by_format,
Some(serde_json::json!({"openai:chat": {"open": true}}))
);
}
}