mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50: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:
113
crates/aether-data/src/repository/billing/memory.rs
Normal file
113
crates/aether-data/src/repository/billing/memory.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{BillingReadRepository, StoredBillingModelContext};
|
||||
use crate::DataLayerError;
|
||||
|
||||
type BillingContextKey = (String, String, Option<String>);
|
||||
type BillingContextMap = BTreeMap<BillingContextKey, StoredBillingModelContext>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryBillingReadRepository {
|
||||
by_key: RwLock<BillingContextMap>,
|
||||
}
|
||||
|
||||
impl InMemoryBillingReadRepository {
|
||||
pub fn seed<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredBillingModelContext>,
|
||||
{
|
||||
let mut by_key = BTreeMap::new();
|
||||
for item in items {
|
||||
by_key.insert(
|
||||
(
|
||||
item.provider_id.clone(),
|
||||
item.global_model_name.clone(),
|
||||
item.provider_api_key_id.clone(),
|
||||
),
|
||||
item,
|
||||
);
|
||||
}
|
||||
Self {
|
||||
by_key: RwLock::new(by_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BillingReadRepository for InMemoryBillingReadRepository {
|
||||
async fn find_model_context(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
let key = (
|
||||
provider_id.to_string(),
|
||||
global_model_name.to_string(),
|
||||
provider_api_key_id.map(ToOwned::to_owned),
|
||||
);
|
||||
let by_key = self.by_key.read().expect("billing repository lock");
|
||||
if let Some(value) = by_key.get(&key) {
|
||||
return Ok(Some(value.clone()));
|
||||
}
|
||||
|
||||
if let Some(value) = by_key
|
||||
.get(&(provider_id.to_string(), global_model_name.to_string(), None))
|
||||
.cloned()
|
||||
{
|
||||
return Ok(Some(value));
|
||||
}
|
||||
|
||||
Ok(by_key
|
||||
.iter()
|
||||
.find(|((stored_provider_id, stored_model_name, _), _)| {
|
||||
stored_provider_id == provider_id && stored_model_name == global_model_name
|
||||
})
|
||||
.map(|(_, value)| value.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::InMemoryBillingReadRepository;
|
||||
use crate::repository::billing::{BillingReadRepository, StoredBillingModelContext};
|
||||
|
||||
fn sample_context() -> StoredBillingModelContext {
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
Some("pay_as_you_go".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
Some(json!({"openai:chat": 0.8})),
|
||||
Some(60),
|
||||
"global-model-1".to_string(),
|
||||
"gpt-5".to_string(),
|
||||
Some(json!({"streaming": true})),
|
||||
Some(0.02),
|
||||
Some(json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0}]})),
|
||||
Some("model-1".to_string()),
|
||||
Some("gpt-5-upstream".to_string()),
|
||||
None,
|
||||
Some(0.01),
|
||||
None,
|
||||
)
|
||||
.expect("billing context should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_provider_without_key_scope() {
|
||||
let repository = InMemoryBillingReadRepository::seed(vec![sample_context()]);
|
||||
let stored = repository
|
||||
.find_model_context("provider-1", Some("key-2"), "gpt-5")
|
||||
.await
|
||||
.expect("lookup should succeed")
|
||||
.expect("context should exist");
|
||||
|
||||
assert_eq!(stored.provider_id, "provider-1");
|
||||
assert_eq!(stored.global_model_name, "gpt-5");
|
||||
}
|
||||
}
|
||||
7
crates/aether-data/src/repository/billing/mod.rs
Normal file
7
crates/aether-data/src/repository/billing/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod memory;
|
||||
mod sql;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemoryBillingReadRepository;
|
||||
pub use sql::SqlxBillingReadRepository;
|
||||
pub use types::{BillingReadRepository, StoredBillingModelContext};
|
||||
121
crates/aether-data/src/repository/billing/sql.rs
Normal file
121
crates/aether-data/src/repository/billing/sql.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use super::types::{BillingReadRepository, StoredBillingModelContext};
|
||||
use crate::DataLayerError;
|
||||
|
||||
const FIND_MODEL_CONTEXT_SQL: &str = r#"
|
||||
SELECT
|
||||
p.id AS provider_id,
|
||||
CAST(p.billing_type AS TEXT) AS provider_billing_type,
|
||||
pak.id AS provider_api_key_id,
|
||||
pak.rate_multipliers AS provider_api_key_rate_multipliers,
|
||||
pak.cache_ttl_minutes AS provider_api_key_cache_ttl_minutes,
|
||||
gm.id AS global_model_id,
|
||||
gm.name AS global_model_name,
|
||||
gm.config AS global_model_config,
|
||||
CAST(gm.default_price_per_request AS DOUBLE PRECISION) AS default_price_per_request,
|
||||
gm.default_tiered_pricing AS default_tiered_pricing,
|
||||
m.id AS model_id,
|
||||
m.provider_model_name AS model_provider_model_name,
|
||||
m.config AS model_config,
|
||||
CAST(m.price_per_request AS DOUBLE PRECISION) AS model_price_per_request,
|
||||
m.tiered_pricing AS model_tiered_pricing
|
||||
FROM providers p
|
||||
INNER JOIN global_models gm
|
||||
ON gm.name = $2
|
||||
AND gm.is_active = TRUE
|
||||
LEFT JOIN models m
|
||||
ON m.global_model_id = gm.id
|
||||
AND m.provider_id = p.id
|
||||
AND m.is_active = TRUE
|
||||
LEFT JOIN provider_api_keys pak
|
||||
ON pak.id = $3
|
||||
AND pak.provider_id = p.id
|
||||
WHERE p.id = $1
|
||||
ORDER BY COALESCE(m.is_available, FALSE) DESC, m.created_at ASC
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxBillingReadRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl SqlxBillingReadRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn find_model_context(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_MODEL_CONTEXT_SQL)
|
||||
.bind(provider_id)
|
||||
.bind(global_model_name)
|
||||
.bind(provider_api_key_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.as_ref().map(map_row).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BillingReadRepository for SqlxBillingReadRepository {
|
||||
async fn find_model_context(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
Self::find_model_context(self, provider_id, provider_api_key_id, global_model_name).await
|
||||
}
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<StoredBillingModelContext, DataLayerError> {
|
||||
StoredBillingModelContext::new(
|
||||
row.try_get("provider_id")?,
|
||||
row.try_get("provider_billing_type")?,
|
||||
row.try_get("provider_api_key_id")?,
|
||||
row.try_get("provider_api_key_rate_multipliers")?,
|
||||
row.try_get::<Option<i32>, _>("provider_api_key_cache_ttl_minutes")?
|
||||
.map(i64::from),
|
||||
row.try_get("global_model_id")?,
|
||||
row.try_get("global_model_name")?,
|
||||
row.try_get("global_model_config")?,
|
||||
row.try_get("default_price_per_request")?,
|
||||
row.try_get("default_tiered_pricing")?,
|
||||
row.try_get("model_id")?,
|
||||
row.try_get("model_provider_model_name")?,
|
||||
row.try_get("model_config")?,
|
||||
row.try_get("model_price_per_request")?,
|
||||
row.try_get("model_tiered_pricing")?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SqlxBillingReadRepository;
|
||||
use crate::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_constructs_from_lazy_pool() {
|
||||
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
||||
database_url: "postgres://localhost/aether".to_string(),
|
||||
min_connections: 1,
|
||||
max_connections: 4,
|
||||
acquire_timeout_ms: 1_000,
|
||||
idle_timeout_ms: 5_000,
|
||||
max_lifetime_ms: 30_000,
|
||||
statement_cache_capacity: 64,
|
||||
require_ssl: false,
|
||||
})
|
||||
.expect("factory should build");
|
||||
|
||||
let pool = factory.connect_lazy().expect("pool should build");
|
||||
let _repository = SqlxBillingReadRepository::new(pool);
|
||||
}
|
||||
}
|
||||
85
crates/aether-data/src/repository/billing/types.rs
Normal file
85
crates/aether-data/src/repository/billing/types.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredBillingModelContext {
|
||||
pub provider_id: String,
|
||||
pub provider_billing_type: Option<String>,
|
||||
pub provider_api_key_id: Option<String>,
|
||||
pub provider_api_key_rate_multipliers: Option<Value>,
|
||||
pub provider_api_key_cache_ttl_minutes: Option<i64>,
|
||||
pub global_model_id: String,
|
||||
pub global_model_name: String,
|
||||
pub global_model_config: Option<Value>,
|
||||
pub default_price_per_request: Option<f64>,
|
||||
pub default_tiered_pricing: Option<Value>,
|
||||
pub model_id: Option<String>,
|
||||
pub model_provider_model_name: Option<String>,
|
||||
pub model_config: Option<Value>,
|
||||
pub model_price_per_request: Option<f64>,
|
||||
pub model_tiered_pricing: Option<Value>,
|
||||
}
|
||||
|
||||
impl StoredBillingModelContext {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
provider_id: String,
|
||||
provider_billing_type: Option<String>,
|
||||
provider_api_key_id: Option<String>,
|
||||
provider_api_key_rate_multipliers: Option<Value>,
|
||||
provider_api_key_cache_ttl_minutes: Option<i64>,
|
||||
global_model_id: String,
|
||||
global_model_name: String,
|
||||
global_model_config: Option<Value>,
|
||||
default_price_per_request: Option<f64>,
|
||||
default_tiered_pricing: Option<Value>,
|
||||
model_id: Option<String>,
|
||||
model_provider_model_name: Option<String>,
|
||||
model_config: Option<Value>,
|
||||
model_price_per_request: Option<f64>,
|
||||
model_tiered_pricing: Option<Value>,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if provider_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"billing.provider_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if global_model_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"billing.global_model_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if global_model_name.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"billing.global_model_name is empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
provider_id,
|
||||
provider_billing_type,
|
||||
provider_api_key_id,
|
||||
provider_api_key_rate_multipliers,
|
||||
provider_api_key_cache_ttl_minutes,
|
||||
global_model_id,
|
||||
global_model_name,
|
||||
global_model_config,
|
||||
default_price_per_request,
|
||||
default_tiered_pricing,
|
||||
model_id,
|
||||
model_provider_model_name,
|
||||
model_config,
|
||||
model_price_per_request,
|
||||
model_tiered_pricing,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BillingReadRepository: Send + Sync {
|
||||
async fn find_model_context(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, crate::DataLayerError>;
|
||||
}
|
||||
Reference in New Issue
Block a user