mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user