mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor: 拆分 admin handler 大文件为模块目录,消除 shared.rs 反模式
- endpoint_keys/pool/architecture 等大文件拆分为独立模块目录 - 删除 crud/query/strategy/system 中的 shared.rs,内容归位到各自模块 - endpoints_admin/models/oauth/write 等模块拆出 payloads/responses/support 子文件 - system/core 下多个 *_routes.rs 合并到 system_routes.rs - 更新 refactor-execution-plan 文档进度
This commit is contained in:
@@ -9,7 +9,7 @@ use crate::handlers::admin::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, query_param_bool,
|
||||
query_param_optional_bool, query_param_value,
|
||||
};
|
||||
use crate::handlers::admin::system::shared::serialize_admin_system_users_export_wallet;
|
||||
use crate::handlers::admin::system::shared::configs::serialize_admin_system_users_export_wallet;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::Body,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub(super) use crate::handlers::admin::provider::endpoint_keys::maybe_build_local_admin_endpoints_keys_response;
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::{endpoint_keys, endpoints_admin};
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::http::Response;
|
||||
@@ -6,8 +7,6 @@ use axum::http::Response;
|
||||
mod extractors;
|
||||
mod health;
|
||||
mod health_builders;
|
||||
mod keys;
|
||||
mod routes;
|
||||
mod rpm;
|
||||
|
||||
pub(crate) use self::health_builders::build_admin_endpoint_health_status_payload;
|
||||
@@ -29,14 +28,17 @@ pub(crate) async fn maybe_build_local_admin_endpoints_response(
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) =
|
||||
keys::maybe_build_local_admin_endpoints_keys_response(state, request_context, request_body)
|
||||
.await?
|
||||
if let Some(response) = endpoint_keys::maybe_build_local_admin_endpoints_keys_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) = routes::maybe_build_local_admin_endpoints_routes_response(
|
||||
if let Some(response) = endpoints_admin::maybe_build_local_admin_endpoints_routes_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub(super) use crate::handlers::admin::provider::endpoints_admin::maybe_build_local_admin_endpoints_routes_response;
|
||||
@@ -18,19 +18,9 @@ pub(crate) use self::global::{
|
||||
build_admin_model_catalog_payload, resolve_admin_global_model_by_id_or_err,
|
||||
};
|
||||
pub(crate) use self::global_models::maybe_build_local_admin_global_models_response;
|
||||
pub(crate) use self::payloads::{
|
||||
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
|
||||
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
|
||||
build_admin_provider_model_payload, build_admin_provider_model_response,
|
||||
build_admin_provider_models_payload, normalize_optional_price,
|
||||
normalize_required_trimmed_string,
|
||||
};
|
||||
pub(crate) use self::routing::{
|
||||
build_admin_assign_global_model_to_providers_payload, build_admin_global_model_routing_payload,
|
||||
};
|
||||
pub(crate) use self::write::{
|
||||
build_admin_batch_assign_global_models_payload, build_admin_global_model_create_record,
|
||||
build_admin_global_model_update_record, build_admin_import_provider_models_payload,
|
||||
build_admin_provider_available_source_models_payload, build_admin_provider_model_create_record,
|
||||
build_admin_provider_model_update_record,
|
||||
build_admin_global_model_create_record, build_admin_global_model_update_record,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
AdminProviderModelListQuery, StoredAdminProviderModel,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use aether_data_contracts::repository::global_models::StoredAdminProviderModel;
|
||||
|
||||
pub(crate) fn model_tiered_pricing_first_tier_value(
|
||||
tiered_pricing: Option<&serde_json::Value>,
|
||||
@@ -31,42 +26,6 @@ fn model_effective_capability(
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_json_values(base: &mut serde_json::Value, overlay: serde_json::Value) {
|
||||
match (base, overlay) {
|
||||
(serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
|
||||
for (key, value) in overlay_map {
|
||||
match base_map.get_mut(&key) {
|
||||
Some(existing) => merge_json_values(existing, value),
|
||||
None => {
|
||||
base_map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(base, overlay) => *base = overlay,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_admin_provider_model_effective_config(
|
||||
model: &StoredAdminProviderModel,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut merged = match model.global_model_config.clone() {
|
||||
Some(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
|
||||
Some(other) => other,
|
||||
None => serde_json::Value::Object(serde_json::Map::new()),
|
||||
};
|
||||
|
||||
if let Some(config) = model.config.clone() {
|
||||
merge_json_values(&mut merged, config);
|
||||
}
|
||||
|
||||
match merged {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::Object(ref map) if map.is_empty() => None,
|
||||
value => Some(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_json::Value {
|
||||
unix_secs_to_rfc3339(value.unwrap_or(now_unix_secs))
|
||||
.map(serde_json::Value::String)
|
||||
@@ -154,144 +113,3 @@ pub(crate) fn admin_provider_model_effective_capability(
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_provider_model_response(
|
||||
model: &StoredAdminProviderModel,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
let effective_tiered_pricing = model
|
||||
.tiered_pricing
|
||||
.clone()
|
||||
.or_else(|| model.global_model_default_tiered_pricing.clone());
|
||||
let effective_config = merge_admin_provider_model_effective_config(model);
|
||||
|
||||
json!({
|
||||
"id": &model.id,
|
||||
"provider_id": &model.provider_id,
|
||||
"global_model_id": &model.global_model_id,
|
||||
"provider_model_name": &model.provider_model_name,
|
||||
"provider_model_mappings": model.provider_model_mappings.clone(),
|
||||
"price_per_request": model.price_per_request,
|
||||
"tiered_pricing": model.tiered_pricing.clone(),
|
||||
"effective_tiered_pricing": effective_tiered_pricing,
|
||||
"effective_input_price": admin_provider_model_effective_input_price(model),
|
||||
"effective_output_price": admin_provider_model_effective_output_price(model),
|
||||
"effective_price_per_request": model
|
||||
.price_per_request
|
||||
.or(model.global_model_default_price_per_request),
|
||||
"supports_vision": model.supports_vision,
|
||||
"supports_function_calling": model.supports_function_calling,
|
||||
"supports_streaming": model.supports_streaming,
|
||||
"supports_extended_thinking": model.supports_extended_thinking,
|
||||
"supports_image_generation": model.supports_image_generation,
|
||||
"effective_supports_vision": admin_provider_model_effective_capability(model, "vision"),
|
||||
"effective_supports_function_calling": admin_provider_model_effective_capability(
|
||||
model,
|
||||
"function_calling",
|
||||
),
|
||||
"effective_supports_streaming": admin_provider_model_effective_capability(model, "streaming"),
|
||||
"effective_supports_extended_thinking": admin_provider_model_effective_capability(
|
||||
model,
|
||||
"extended_thinking",
|
||||
),
|
||||
"effective_supports_image_generation": admin_provider_model_effective_capability(
|
||||
model,
|
||||
"image_generation",
|
||||
),
|
||||
"is_active": model.is_active,
|
||||
"is_available": model.is_available,
|
||||
"config": model.config.clone(),
|
||||
"effective_config": effective_config,
|
||||
"global_model_name": model.global_model_name.clone(),
|
||||
"global_model_display_name": model.global_model_display_name.clone(),
|
||||
"created_at": timestamp_or_now(model.created_at_unix_secs, now_unix_secs),
|
||||
"updated_at": timestamp_or_now(model.updated_at_unix_secs, now_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
skip: usize,
|
||||
limit: usize,
|
||||
is_active: Option<bool>,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_global_model_data_reader() {
|
||||
return None;
|
||||
}
|
||||
let provider = state
|
||||
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
||||
.await
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.next()?;
|
||||
let mut models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider.id,
|
||||
is_active,
|
||||
offset: skip,
|
||||
limit,
|
||||
})
|
||||
.await
|
||||
.ok()?;
|
||||
models.sort_by(|left, right| {
|
||||
left.provider_model_name
|
||||
.cmp(&right.provider_model_name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
Some(serde_json::Value::Array(
|
||||
models
|
||||
.iter()
|
||||
.map(|model| build_admin_provider_model_response(model, now_unix_secs))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_model_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
model_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_global_model_data_reader() {
|
||||
return None;
|
||||
}
|
||||
let model = state
|
||||
.get_admin_provider_model(provider_id, model_id)
|
||||
.await
|
||||
.ok()??;
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
Some(build_admin_provider_model_response(&model, now_unix_secs))
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_provider_model_name_exists(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
provider_model_name: &str,
|
||||
exclude_model_id: Option<&str>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let target = provider_model_name.trim();
|
||||
if target.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider_id.to_string(),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10_000,
|
||||
})
|
||||
.await?;
|
||||
Ok(models.into_iter().any(|model| {
|
||||
model.provider_model_name == target
|
||||
&& exclude_model_id.is_none_or(|exclude| model.id != exclude)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
use super::{
|
||||
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
|
||||
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
|
||||
normalize_optional_price, normalize_required_trimmed_string,
|
||||
resolve_admin_global_model_by_id_or_err,
|
||||
};
|
||||
use super::payloads::{normalize_optional_price, normalize_required_trimmed_string};
|
||||
use crate::handlers::admin::model::shared::{
|
||||
AdminGlobalModelCreateRequest, AdminGlobalModelUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
AdminImportProviderModelsRequest, AdminProviderModelCreateRequest,
|
||||
AdminProviderModelUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::shared::{
|
||||
normalize_json_array, normalize_json_object, normalize_string_list,
|
||||
};
|
||||
use crate::handlers::admin::shared::{normalize_json_object, normalize_string_list};
|
||||
use crate::AppState;
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
AdminProviderModelListQuery, CreateAdminGlobalModelRecord, StoredAdminGlobalModel,
|
||||
StoredAdminProviderModel, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
|
||||
CreateAdminGlobalModelRecord, StoredAdminGlobalModel, UpdateAdminGlobalModelRecord,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) async fn build_admin_global_model_create_record(
|
||||
@@ -116,419 +103,3 @@ pub(crate) async fn build_admin_global_model_update_record(
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_model_create_record(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
payload: AdminProviderModelCreateRequest,
|
||||
) -> Result<UpsertAdminProviderModelRecord, String> {
|
||||
let provider_model_name =
|
||||
normalize_required_trimmed_string(&payload.provider_model_name, "provider_model_name")?;
|
||||
if admin_provider_model_name_exists(state, provider_id, &provider_model_name, None)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
{
|
||||
return Err(format!("模型 '{provider_model_name}' 已存在"));
|
||||
}
|
||||
let global_model_id =
|
||||
normalize_required_trimmed_string(&payload.global_model_id, "global_model_id")?;
|
||||
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
|
||||
let price_per_request =
|
||||
normalize_optional_price(payload.price_per_request, "price_per_request")?;
|
||||
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
|
||||
let provider_model_mappings =
|
||||
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?;
|
||||
let config = normalize_json_object(payload.config, "config")?;
|
||||
UpsertAdminProviderModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
provider_id.to_string(),
|
||||
global_model_id,
|
||||
provider_model_name,
|
||||
provider_model_mappings,
|
||||
price_per_request,
|
||||
tiered_pricing,
|
||||
payload.supports_vision,
|
||||
payload.supports_function_calling,
|
||||
payload.supports_streaming,
|
||||
payload.supports_extended_thinking,
|
||||
None,
|
||||
payload.is_active.unwrap_or(true),
|
||||
true,
|
||||
config,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_model_update_record(
|
||||
state: &AppState,
|
||||
existing: &StoredAdminProviderModel,
|
||||
raw_payload: &serde_json::Map<String, serde_json::Value>,
|
||||
payload: AdminProviderModelUpdateRequest,
|
||||
) -> Result<UpsertAdminProviderModelRecord, String> {
|
||||
let provider_model_name = if let Some(value) = raw_payload.get("provider_model_name") {
|
||||
let Some(name) = payload.provider_model_name.as_deref() else {
|
||||
return Err(if value.is_null() {
|
||||
"provider_model_name 不能为空".to_string()
|
||||
} else {
|
||||
"provider_model_name 必须是字符串".to_string()
|
||||
});
|
||||
};
|
||||
let name = normalize_required_trimmed_string(name, "provider_model_name")?;
|
||||
if admin_provider_model_name_exists(state, &existing.provider_id, &name, Some(&existing.id))
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
{
|
||||
return Err(format!("模型 '{name}' 已存在"));
|
||||
}
|
||||
name
|
||||
} else {
|
||||
existing.provider_model_name.clone()
|
||||
};
|
||||
|
||||
let global_model_id = if let Some(value) = raw_payload.get("global_model_id") {
|
||||
let Some(global_model_id) = payload.global_model_id.as_deref() else {
|
||||
return Err(if value.is_null() {
|
||||
"global_model_id 不能为空".to_string()
|
||||
} else {
|
||||
"global_model_id 必须是字符串".to_string()
|
||||
});
|
||||
};
|
||||
let global_model_id =
|
||||
normalize_required_trimmed_string(global_model_id, "global_model_id")?;
|
||||
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
|
||||
global_model_id
|
||||
} else {
|
||||
existing.global_model_id.clone()
|
||||
};
|
||||
|
||||
let price_per_request = if raw_payload.contains_key("price_per_request") {
|
||||
normalize_optional_price(payload.price_per_request, "price_per_request")?
|
||||
} else {
|
||||
existing.price_per_request
|
||||
};
|
||||
let tiered_pricing = if raw_payload.contains_key("tiered_pricing") {
|
||||
normalize_json_object(payload.tiered_pricing, "tiered_pricing")?
|
||||
} else {
|
||||
existing.tiered_pricing.clone()
|
||||
};
|
||||
let provider_model_mappings = if raw_payload.contains_key("provider_model_mappings") {
|
||||
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?
|
||||
} else {
|
||||
existing.provider_model_mappings.clone()
|
||||
};
|
||||
let config = if raw_payload.contains_key("config") {
|
||||
normalize_json_object(payload.config, "config")?
|
||||
} else {
|
||||
existing.config.clone()
|
||||
};
|
||||
|
||||
UpsertAdminProviderModelRecord::new(
|
||||
existing.id.clone(),
|
||||
existing.provider_id.clone(),
|
||||
global_model_id,
|
||||
provider_model_name,
|
||||
provider_model_mappings,
|
||||
price_per_request,
|
||||
tiered_pricing,
|
||||
if raw_payload.contains_key("supports_vision") {
|
||||
payload.supports_vision
|
||||
} else {
|
||||
existing.supports_vision
|
||||
},
|
||||
if raw_payload.contains_key("supports_function_calling") {
|
||||
payload.supports_function_calling
|
||||
} else {
|
||||
existing.supports_function_calling
|
||||
},
|
||||
if raw_payload.contains_key("supports_streaming") {
|
||||
payload.supports_streaming
|
||||
} else {
|
||||
existing.supports_streaming
|
||||
},
|
||||
if raw_payload.contains_key("supports_extended_thinking") {
|
||||
payload.supports_extended_thinking
|
||||
} else {
|
||||
existing.supports_extended_thinking
|
||||
},
|
||||
existing.supports_image_generation,
|
||||
payload.is_active.unwrap_or(existing.is_active),
|
||||
payload.is_available.unwrap_or(existing.is_available),
|
||||
config,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_available_source_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_global_model_data_reader() || !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
let provider = state
|
||||
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
||||
.await
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.next()?;
|
||||
let models = state
|
||||
.list_admin_provider_available_source_models(&provider.id)
|
||||
.await
|
||||
.ok()?;
|
||||
let mut by_global_model = BTreeMap::<String, StoredAdminProviderModel>::new();
|
||||
for model in models {
|
||||
by_global_model
|
||||
.entry(model.global_model_id.clone())
|
||||
.or_insert(model);
|
||||
}
|
||||
let mut payload_models = by_global_model
|
||||
.into_values()
|
||||
.map(|model| {
|
||||
json!({
|
||||
"global_model_name": model.global_model_name,
|
||||
"display_name": model.global_model_display_name,
|
||||
"provider_model_name": model.provider_model_name,
|
||||
"model_id": model.id,
|
||||
"price": {
|
||||
"input_price_per_1m": admin_provider_model_effective_input_price(&model),
|
||||
"output_price_per_1m": admin_provider_model_effective_output_price(&model),
|
||||
"cache_creation_price_per_1m": serde_json::Value::Null,
|
||||
"cache_read_price_per_1m": serde_json::Value::Null,
|
||||
"price_per_request": model.price_per_request.or(model.global_model_default_price_per_request),
|
||||
},
|
||||
"capabilities": json!({
|
||||
"supports_vision": admin_provider_model_effective_capability(&model, "vision"),
|
||||
"supports_function_calling": admin_provider_model_effective_capability(&model, "function_calling"),
|
||||
"supports_streaming": admin_provider_model_effective_capability(&model, "streaming"),
|
||||
}),
|
||||
"is_active": model.is_active,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let total = payload_models.len();
|
||||
payload_models.sort_by(|left, right| {
|
||||
left.get("global_model_name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.cmp(
|
||||
&right
|
||||
.get("global_model_name")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
)
|
||||
});
|
||||
Some(json!({
|
||||
"models": payload_models,
|
||||
"total": total,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_batch_assign_global_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
global_model_ids: Vec<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let existing_models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider_id.to_string(),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10_000,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let existing_global_model_ids = existing_models
|
||||
.into_iter()
|
||||
.map(|model| model.global_model_id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
let mut success = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for global_model_id in global_model_ids {
|
||||
let global_model_id = global_model_id.trim().to_string();
|
||||
if global_model_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let global_model =
|
||||
match resolve_admin_global_model_by_id_or_err(state, &global_model_id).await {
|
||||
Ok(model) => model,
|
||||
Err(detail) => {
|
||||
errors.push(json!({
|
||||
"global_model_id": global_model_id,
|
||||
"error": detail,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if existing_global_model_ids.contains(&global_model.id) {
|
||||
errors.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"error": "Model already exists",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let record = UpsertAdminProviderModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
provider_id.to_string(),
|
||||
global_model.id.clone(),
|
||||
global_model.name.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
match state.create_admin_provider_model(&record).await {
|
||||
Ok(Some(created)) => success.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"global_model_name": global_model.name,
|
||||
"provider_model_id": created.id,
|
||||
})),
|
||||
Ok(None) => errors.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"error": "Create provider model failed",
|
||||
})),
|
||||
Err(err) => errors.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"error": format!("{err:?}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"success": success,
|
||||
"errors": errors,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_import_provider_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
payload: AdminImportProviderModelsRequest,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let default_pricing = json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 0.0,
|
||||
"output_price_per_1m": 0.0,
|
||||
}]
|
||||
});
|
||||
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
|
||||
|
||||
let existing_models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider_id.to_string(),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10_000,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let mut existing_by_name = existing_models
|
||||
.iter()
|
||||
.map(|model| (model.provider_model_name.clone(), model.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let mut success = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for model_id in payload.model_ids {
|
||||
let trimmed = model_id.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 100 {
|
||||
errors.push(json!({
|
||||
"model_id": if trimmed.is_empty() { "<empty>" } else { trimmed },
|
||||
"error": "Invalid model_id: must be 1-100 characters",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = existing_by_name.get(trimmed) {
|
||||
success.push(json!({
|
||||
"model_id": trimmed,
|
||||
"global_model_id": existing.global_model_id,
|
||||
"global_model_name": existing.global_model_name,
|
||||
"provider_model_id": existing.id,
|
||||
"created_global_model": false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut created_global_model = false;
|
||||
let global_model = if let Some(existing) = state
|
||||
.get_admin_global_model_by_name(trimmed)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
{
|
||||
existing
|
||||
} else {
|
||||
let created = state
|
||||
.create_admin_global_model(
|
||||
&CreateAdminGlobalModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
trimmed.to_string(),
|
||||
trimmed.to_string(),
|
||||
true,
|
||||
payload.price_per_request,
|
||||
tiered_pricing
|
||||
.clone()
|
||||
.or_else(|| Some(default_pricing.clone())),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|err| err.to_string())?,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let Some(created) = created else {
|
||||
errors.push(json!({"model_id": trimmed, "error": "Create GlobalModel failed"}));
|
||||
continue;
|
||||
};
|
||||
created_global_model = true;
|
||||
created
|
||||
};
|
||||
|
||||
let record = UpsertAdminProviderModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
provider_id.to_string(),
|
||||
global_model.id.clone(),
|
||||
trimmed.to_string(),
|
||||
None,
|
||||
payload.price_per_request,
|
||||
tiered_pricing.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
match state.create_admin_provider_model(&record).await {
|
||||
Ok(Some(created)) => {
|
||||
existing_by_name.insert(trimmed.to_string(), created.clone());
|
||||
success.push(json!({
|
||||
"model_id": trimmed,
|
||||
"global_model_id": global_model.id,
|
||||
"global_model_name": global_model.name,
|
||||
"provider_model_id": created.id,
|
||||
"created_global_model": created_global_model,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
errors.push(json!({"model_id": trimmed, "error": "Create model failed"}));
|
||||
}
|
||||
Err(err) => {
|
||||
errors.push(json!({"model_id": trimmed, "error": format!("{err:?}")}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "success": success, "errors": errors }))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
mod responses;
|
||||
mod routes;
|
||||
mod shared;
|
||||
|
||||
pub(crate) use routes::maybe_build_local_admin_providers_response;
|
||||
use shared::*;
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
use super::shared::build_admin_providers_data_unavailable_response;
|
||||
use super::responses::build_admin_providers_data_unavailable_response;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::delete_task::{
|
||||
build_admin_provider_mapping_preview_payload, run_admin_provider_delete_task,
|
||||
};
|
||||
use crate::handlers::admin::provider::pool::{
|
||||
use crate::handlers::admin::provider::pool::runtime::{
|
||||
build_admin_provider_pool_status_payload, clear_admin_provider_pool_cooldown,
|
||||
reset_admin_provider_pool_cost,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_clear_pool_cooldown_parts, admin_provider_delete_task_parts,
|
||||
admin_provider_id_for_health_monitor, admin_provider_id_for_manage_path,
|
||||
admin_provider_id_for_mapping_preview, admin_provider_id_for_pool_status,
|
||||
admin_provider_id_for_summary, admin_provider_reset_pool_cost_parts,
|
||||
build_admin_provider_delete_task_payload, is_admin_providers_root,
|
||||
put_admin_provider_delete_task, AdminProviderCreateRequest, AdminProviderUpdateRequest,
|
||||
admin_provider_id_for_summary, admin_provider_reset_pool_cost_parts, is_admin_providers_root,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminProviderCreateRequest, AdminProviderUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
build_admin_provider_delete_task_payload, put_admin_provider_delete_task,
|
||||
};
|
||||
use crate::handlers::admin::provider::summary::{
|
||||
build_admin_provider_health_monitor_payload, build_admin_provider_summary_payload,
|
||||
build_admin_provider_summary_value, build_admin_providers_payload,
|
||||
build_admin_providers_summary_payload,
|
||||
};
|
||||
use crate::handlers::admin::provider::write::{
|
||||
use crate::handlers::admin::provider::write::provider::{
|
||||
build_admin_create_provider_record, build_admin_fixed_provider_endpoint_record,
|
||||
build_admin_update_provider_record,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
put_admin_provider_delete_task, ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT,
|
||||
ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS, ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS,
|
||||
};
|
||||
|
||||
@@ -1,754 +1,29 @@
|
||||
use super::oauth::{
|
||||
normalize_string_id_list, refresh_antigravity_provider_quota_locally,
|
||||
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
|
||||
};
|
||||
use super::write::{
|
||||
build_admin_create_provider_key_record, build_admin_export_key_payload,
|
||||
build_admin_provider_keys_payload, build_admin_reveal_key_payload,
|
||||
build_admin_update_provider_key_record,
|
||||
};
|
||||
mod mutations;
|
||||
mod quota;
|
||||
mod reads;
|
||||
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys,
|
||||
admin_provider_id_for_refresh_quota, admin_reveal_key_id, admin_update_key_id,
|
||||
AdminProviderKeyBatchDeleteRequest, AdminProviderKeyCreateRequest,
|
||||
AdminProviderKeyUpdateRequest, AdminProviderQuotaRefreshRequest, OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||
};
|
||||
use crate::handlers::admin::shared::{
|
||||
attach_admin_audit_response, build_admin_provider_key_response, query_param_value,
|
||||
};
|
||||
use crate::handlers::public::build_admin_keys_grouped_by_format_payload;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_endpoints_keys_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("keys_grouped_by_format")
|
||||
&& request_context.request_path == "/api/admin/endpoints/keys/grouped-by-format"
|
||||
{
|
||||
let Some(payload) = build_admin_keys_grouped_by_format_payload(state).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(Some(Json(payload).into_response()));
|
||||
if let Some(response) = reads::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("reveal_key")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
&& request_context.request_path.ends_with("/reveal")
|
||||
{
|
||||
let Some(key_id) = admin_reveal_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(match build_admin_reveal_key_payload(state, &key) {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_provider_key_revealed",
|
||||
"reveal_provider_key",
|
||||
"provider_key",
|
||||
&key_id,
|
||||
),
|
||||
Err(detail) => (
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
}));
|
||||
if let Some(response) = mutations::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("export_key")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
&& request_context.request_path.ends_with("/export")
|
||||
{
|
||||
let Some(key_id) = admin_export_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match build_admin_export_key_payload(state, &key).await {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_provider_key_exported",
|
||||
"export_provider_key",
|
||||
"provider_key_export",
|
||||
&key_id,
|
||||
),
|
||||
Err(detail) => (
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("update_key")
|
||||
&& request_context.request_method == http::Method::PUT
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
{
|
||||
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(raw_payload) = raw_value.as_object().cloned() else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload = match serde_json::from_value::<AdminProviderKeyUpdateRequest>(raw_value) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(existing_key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&existing_key.provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {} 不存在", existing_key.provider_id) })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let updated_record = match build_admin_update_provider_key_record(
|
||||
state,
|
||||
&provider,
|
||||
&existing_key,
|
||||
&raw_payload,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(updated) = state.update_provider_catalog_key(&updated_record).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
return Ok(Some(
|
||||
Json(build_admin_provider_key_response(
|
||||
state,
|
||||
&updated,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("delete_key")
|
||||
&& request_context.request_method == http::Method::DELETE
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
{
|
||||
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(_existing_key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if !state.delete_provider_catalog_key(&key_id).await? {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": format!("Key {key_id} 已删除")
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("batch_delete_keys")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context.request_path == "/api/admin/endpoints/keys/batch-delete"
|
||||
{
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload =
|
||||
match serde_json::from_slice::<AdminProviderKeyBatchDeleteRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if payload.ids.len() > 100 {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "ids 最多 100 个" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
if payload.ids.is_empty() {
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success_count": 0,
|
||||
"failed_count": 0,
|
||||
"failed": []
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
let found_keys = state
|
||||
.read_provider_catalog_keys_by_ids(&payload.ids)
|
||||
.await?;
|
||||
let found_ids = found_keys
|
||||
.iter()
|
||||
.map(|key| key.id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut failed = payload
|
||||
.ids
|
||||
.iter()
|
||||
.filter(|key_id| !found_ids.contains(*key_id))
|
||||
.map(|key_id| json!({ "id": key_id, "error": "not found" }))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut success_count = 0usize;
|
||||
for key_id in found_ids {
|
||||
if state.delete_provider_catalog_key(&key_id).await? {
|
||||
success_count += 1;
|
||||
} else {
|
||||
failed.push(json!({ "id": key_id, "error": "not found" }));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success_count": success_count,
|
||||
"failed_count": failed.len(),
|
||||
"failed": failed,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("clear_oauth_invalid")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
&& request_context
|
||||
.request_path
|
||||
.ends_with("/clear-oauth-invalid")
|
||||
{
|
||||
let Some(key_id) = admin_clear_oauth_invalid_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if key.oauth_invalid_at_unix_secs.is_none() {
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": "该 Key 当前无失效标记,无需清除"
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.clear_provider_catalog_key_oauth_invalid_marker(&key_id)
|
||||
.await?;
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": "已清除 OAuth 失效标记"
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("refresh_quota")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
&& request_context.request_path.ends_with("/refresh-quota")
|
||||
{
|
||||
let Some(provider_id) = admin_provider_id_for_refresh_quota(&request_context.request_path)
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
|
||||
let normalized_provider_type = provider.provider_type.trim().to_ascii_lowercase();
|
||||
|
||||
let payload = if let Some(request_body) = request_body {
|
||||
match serde_json::from_slice::<AdminProviderQuotaRefreshRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AdminProviderQuotaRefreshRequest { key_ids: None }
|
||||
};
|
||||
|
||||
let raw_key_ids = payload.key_ids;
|
||||
let selected_key_ids = normalize_string_id_list(raw_key_ids.clone());
|
||||
let explicit_key_ids_requested = raw_key_ids.is_some();
|
||||
let endpoints = state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
|
||||
.await?;
|
||||
let endpoint = match normalized_provider_type.as_str() {
|
||||
"codex" => endpoints.into_iter().find(|endpoint| {
|
||||
endpoint.is_active
|
||||
&& endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:cli")
|
||||
}),
|
||||
"antigravity" => endpoints.into_iter().find(|endpoint| {
|
||||
endpoint.is_active
|
||||
&& (endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("gemini:chat")
|
||||
|| endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("gemini:cli"))
|
||||
}),
|
||||
"kiro" => endpoints
|
||||
.iter()
|
||||
.find(|endpoint| {
|
||||
endpoint.is_active
|
||||
&& endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("claude:cli")
|
||||
})
|
||||
.cloned()
|
||||
.or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let Some(endpoint) = endpoint else {
|
||||
let detail = match normalized_provider_type.as_str() {
|
||||
"codex" => "找不到有效的 openai:cli 端点",
|
||||
"antigravity" => "找不到有效的 gemini:chat/gemini:cli 端点",
|
||||
"kiro" => "找不到有效的 Kiro 端点",
|
||||
_ => "找不到有效端点",
|
||||
};
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
|
||||
let mut keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider_id))
|
||||
.await?;
|
||||
keys = if let Some(selected_key_ids) = selected_key_ids.as_ref() {
|
||||
if selected_key_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let selected = selected_key_ids.iter().cloned().collect::<BTreeSet<_>>();
|
||||
keys.into_iter()
|
||||
.filter(|key| selected.contains(&key.id))
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
keys.into_iter()
|
||||
.filter(|key| {
|
||||
key.is_active
|
||||
|| key
|
||||
.oauth_invalid_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if explicit_key_ids_requested && selected_key_ids.is_none() {
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"total": 0,
|
||||
"results": [],
|
||||
"message": "未提供可刷新的 Key",
|
||||
"auto_removed": 0,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if keys.is_empty() {
|
||||
let message = if explicit_key_ids_requested {
|
||||
"未提供可刷新的 Key"
|
||||
} else {
|
||||
"没有可刷新的 Key"
|
||||
};
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"total": 0,
|
||||
"results": [],
|
||||
"message": message,
|
||||
"auto_removed": 0,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(payload) = (match normalized_provider_type.as_str() {
|
||||
"codex" => {
|
||||
refresh_codex_provider_quota_locally(state, &provider, &endpoint, keys).await?
|
||||
}
|
||||
"kiro" => {
|
||||
refresh_kiro_provider_quota_locally(state, &provider, &endpoint, keys).await?
|
||||
}
|
||||
"antigravity" => {
|
||||
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys)
|
||||
.await?
|
||||
}
|
||||
_ => None,
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(Some(Json(payload).into_response()));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("create_provider_key")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
&& request_context.request_path.ends_with("/keys")
|
||||
{
|
||||
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(None);
|
||||
}
|
||||
let payload = match serde_json::from_slice::<AdminProviderKeyCreateRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let record = match build_admin_create_provider_key_record(state, &provider, payload).await {
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(created) = state.create_provider_catalog_key(&record).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
return Ok(Some(
|
||||
Json(build_admin_provider_key_response(
|
||||
state,
|
||||
&created,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("list_provider_keys")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
&& request_context.request_path.ends_with("/keys")
|
||||
{
|
||||
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(100);
|
||||
return Ok(Some(
|
||||
match build_admin_provider_keys_payload(state, &provider_id, skip, limit).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
if let Some(response) = quota::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_clear_oauth_invalid_key_id, admin_provider_id_for_keys, admin_update_key_id,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminProviderKeyBatchDeleteRequest, AdminProviderKeyCreateRequest,
|
||||
AdminProviderKeyUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::shared::build_admin_provider_key_response;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::super::write::keys::{
|
||||
build_admin_create_provider_key_record, build_admin_update_provider_key_record,
|
||||
};
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("update_key")
|
||||
&& request_context.request_method == http::Method::PUT
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
{
|
||||
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(raw_payload) = raw_value.as_object().cloned() else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload = match serde_json::from_value::<AdminProviderKeyUpdateRequest>(raw_value) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(existing_key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&existing_key.provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {} 不存在", existing_key.provider_id) })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let updated_record = match build_admin_update_provider_key_record(
|
||||
state,
|
||||
&provider,
|
||||
&existing_key,
|
||||
&raw_payload,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(updated) = state.update_provider_catalog_key(&updated_record).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
return Ok(Some(
|
||||
Json(build_admin_provider_key_response(
|
||||
state,
|
||||
&updated,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("delete_key")
|
||||
&& request_context.request_method == http::Method::DELETE
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
{
|
||||
let Some(key_id) = admin_update_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(_existing_key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if !state.delete_provider_catalog_key(&key_id).await? {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": format!("Key {key_id} 已删除")
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("batch_delete_keys")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context.request_path == "/api/admin/endpoints/keys/batch-delete"
|
||||
{
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload =
|
||||
match serde_json::from_slice::<AdminProviderKeyBatchDeleteRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if payload.ids.len() > 100 {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "ids 最多 100 个" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
if payload.ids.is_empty() {
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success_count": 0,
|
||||
"failed_count": 0,
|
||||
"failed": []
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
let found_keys = state
|
||||
.read_provider_catalog_keys_by_ids(&payload.ids)
|
||||
.await?;
|
||||
let found_ids = found_keys
|
||||
.iter()
|
||||
.map(|key| key.id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut failed = payload
|
||||
.ids
|
||||
.iter()
|
||||
.filter(|key_id| !found_ids.contains(*key_id))
|
||||
.map(|key_id| json!({ "id": key_id, "error": "not found" }))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut success_count = 0usize;
|
||||
for key_id in found_ids {
|
||||
if state.delete_provider_catalog_key(&key_id).await? {
|
||||
success_count += 1;
|
||||
} else {
|
||||
failed.push(json!({ "id": key_id, "error": "not found" }));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success_count": success_count,
|
||||
"failed_count": failed.len(),
|
||||
"failed": failed,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("clear_oauth_invalid")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
&& request_context
|
||||
.request_path
|
||||
.ends_with("/clear-oauth-invalid")
|
||||
{
|
||||
let Some(key_id) = admin_clear_oauth_invalid_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if key.oauth_invalid_at_unix_secs.is_none() {
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": "该 Key 当前无失效标记,无需清除"
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.clear_provider_catalog_key_oauth_invalid_marker(&key_id)
|
||||
.await?;
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": "已清除 OAuth 失效标记"
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("create_provider_key")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
&& request_context.request_path.ends_with("/keys")
|
||||
{
|
||||
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(None);
|
||||
}
|
||||
let payload = match serde_json::from_slice::<AdminProviderKeyCreateRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let record = match build_admin_create_provider_key_record(state, &provider, payload).await {
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(created) = state.create_provider_catalog_key(&record).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
return Ok(Some(
|
||||
Json(build_admin_provider_key_response(
|
||||
state,
|
||||
&created,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_id_for_refresh_quota;
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminProviderQuotaRefreshRequest, OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally;
|
||||
use super::super::oauth::quota::codex::refresh_codex_provider_quota_locally;
|
||||
use super::super::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
|
||||
use super::super::oauth::quota::shared::normalize_string_id_list;
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() != Some("endpoints_manage")
|
||||
|| decision.route_kind.as_deref() != Some("refresh_quota")
|
||||
|| request_context.request_method != http::Method::POST
|
||||
|| !request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
|| !request_context.request_path.ends_with("/refresh-quota")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(provider_id) = admin_provider_id_for_refresh_quota(&request_context.request_path)
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
|
||||
let normalized_provider_type = provider.provider_type.trim().to_ascii_lowercase();
|
||||
|
||||
let payload = if let Some(request_body) = request_body {
|
||||
match serde_json::from_slice::<AdminProviderQuotaRefreshRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AdminProviderQuotaRefreshRequest { key_ids: None }
|
||||
};
|
||||
|
||||
let raw_key_ids = payload.key_ids;
|
||||
let selected_key_ids = normalize_string_id_list(raw_key_ids.clone());
|
||||
let explicit_key_ids_requested = raw_key_ids.is_some();
|
||||
let endpoints = state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
|
||||
.await?;
|
||||
let endpoint = match normalized_provider_type.as_str() {
|
||||
"codex" => endpoints.into_iter().find(|endpoint| {
|
||||
endpoint.is_active
|
||||
&& endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:cli")
|
||||
}),
|
||||
"antigravity" => endpoints.into_iter().find(|endpoint| {
|
||||
endpoint.is_active
|
||||
&& (endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("gemini:chat")
|
||||
|| endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("gemini:cli"))
|
||||
}),
|
||||
"kiro" => endpoints
|
||||
.iter()
|
||||
.find(|endpoint| {
|
||||
endpoint.is_active
|
||||
&& endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("claude:cli")
|
||||
})
|
||||
.cloned()
|
||||
.or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let Some(endpoint) = endpoint else {
|
||||
let detail = match normalized_provider_type.as_str() {
|
||||
"codex" => "找不到有效的 openai:cli 端点",
|
||||
"antigravity" => "找不到有效的 gemini:chat/gemini:cli 端点",
|
||||
"kiro" => "找不到有效的 Kiro 端点",
|
||||
_ => "找不到有效端点",
|
||||
};
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
|
||||
let mut keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider_id))
|
||||
.await?;
|
||||
keys = if let Some(selected_key_ids) = selected_key_ids.as_ref() {
|
||||
if selected_key_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let selected = selected_key_ids.iter().cloned().collect::<BTreeSet<_>>();
|
||||
keys.into_iter()
|
||||
.filter(|key| selected.contains(&key.id))
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
keys.into_iter()
|
||||
.filter(|key| {
|
||||
key.is_active
|
||||
|| key
|
||||
.oauth_invalid_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if explicit_key_ids_requested && selected_key_ids.is_none() {
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"total": 0,
|
||||
"results": [],
|
||||
"message": "未提供可刷新的 Key",
|
||||
"auto_removed": 0,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if keys.is_empty() {
|
||||
let message = if explicit_key_ids_requested {
|
||||
"未提供可刷新的 Key"
|
||||
} else {
|
||||
"没有可刷新的 Key"
|
||||
};
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"total": 0,
|
||||
"results": [],
|
||||
"message": message,
|
||||
"auto_removed": 0,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(payload) = (match normalized_provider_type.as_str() {
|
||||
"codex" => refresh_codex_provider_quota_locally(state, &provider, &endpoint, keys).await?,
|
||||
"kiro" => refresh_kiro_provider_quota_locally(state, &provider, &endpoint, keys).await?,
|
||||
"antigravity" => {
|
||||
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys).await?
|
||||
}
|
||||
_ => None,
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Json(payload).into_response()))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_export_key_id, admin_provider_id_for_keys, admin_reveal_key_id,
|
||||
};
|
||||
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
|
||||
use crate::handlers::public::build_admin_keys_grouped_by_format_payload;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::write::keys::build_admin_provider_keys_payload;
|
||||
use super::super::write::reveal::{build_admin_export_key_payload, build_admin_reveal_key_payload};
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
_request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("keys_grouped_by_format")
|
||||
&& request_context.request_path == "/api/admin/endpoints/keys/grouped-by-format"
|
||||
{
|
||||
let Some(payload) = build_admin_keys_grouped_by_format_payload(state).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(Some(Json(payload).into_response()));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("reveal_key")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
&& request_context.request_path.ends_with("/reveal")
|
||||
{
|
||||
let Some(key_id) = admin_reveal_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(match build_admin_reveal_key_payload(state, &key) {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_provider_key_revealed",
|
||||
"reveal_provider_key",
|
||||
"provider_key",
|
||||
&key_id,
|
||||
),
|
||||
Err(detail) => (
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("export_key")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/keys/")
|
||||
&& request_context.request_path.ends_with("/export")
|
||||
{
|
||||
let Some(key_id) = admin_export_key_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Key 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Key {key_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match build_admin_export_key_payload(state, &key).await {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_provider_key_exported",
|
||||
"export_provider_key",
|
||||
"provider_key_export",
|
||||
&key_id,
|
||||
),
|
||||
Err(detail) => (
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("list_provider_keys")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
&& request_context.request_path.ends_with("/keys")
|
||||
{
|
||||
let Some(provider_id) = admin_provider_id_for_keys(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(100);
|
||||
return Ok(Some(
|
||||
match build_admin_provider_keys_payload(state, &provider_id, skip, limit).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::builders::build_admin_create_provider_endpoint_record;
|
||||
use super::extractors::admin_provider_id_for_endpoints;
|
||||
use super::payloads::{build_admin_provider_endpoint_response, AdminProviderEndpointCreateRequest};
|
||||
use super::support::build_admin_endpoints_data_unavailable_response;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() != Some("endpoints_manage")
|
||||
|| decision.route_kind.as_deref() != Some("create_endpoint")
|
||||
|| request_context.request_method != http::Method::POST
|
||||
|| !request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
|| !request_context.request_path.ends_with("/endpoints")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
|
||||
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<AdminProviderEndpointCreateRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let record = match build_admin_create_provider_endpoint_record(state, &provider, payload).await
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(created) = state.create_provider_catalog_endpoint(&record).await? else {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Some(
|
||||
Json(build_admin_provider_endpoint_response(
|
||||
&created,
|
||||
&provider.name,
|
||||
0,
|
||||
0,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::extractors::admin_default_body_rules_api_format;
|
||||
use crate::api::ai::admin_default_body_rules_for_signature;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
_state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
_request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() != Some("endpoints_manage")
|
||||
|| decision.route_kind.as_deref() != Some("default_body_rules")
|
||||
|| !request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/defaults/")
|
||||
|| !request_context.request_path.ends_with("/body-rules")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(api_format) = admin_default_body_rules_api_format(&request_context.request_path)
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "无效的 api_format" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let provider_type = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"provider_type",
|
||||
);
|
||||
|
||||
Ok(Some(
|
||||
match admin_default_body_rules_for_signature(&api_format, provider_type.as_deref()) {
|
||||
Some((normalized_api_format, body_rules)) => Json(json!({
|
||||
"api_format": normalized_api_format,
|
||||
"body_rules": body_rules,
|
||||
}))
|
||||
.into_response(),
|
||||
None => (
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": format!("无效的 api_format: {api_format}") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::extractors::admin_endpoint_id;
|
||||
use super::payloads::key_api_formats_without_entry;
|
||||
use super::support::build_admin_endpoints_data_unavailable_response;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
_request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() != Some("endpoints_manage")
|
||||
|| decision.route_kind.as_deref() != Some("delete_endpoint")
|
||||
|| request_context.request_method != http::Method::DELETE
|
||||
|| !request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
|
||||
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Endpoint 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(existing_endpoint) = state
|
||||
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(
|
||||
&existing_endpoint.provider_id,
|
||||
))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut affected_keys_count = 0usize;
|
||||
for key in keys {
|
||||
let Some(updated_formats) =
|
||||
key_api_formats_without_entry(&key, existing_endpoint.api_format.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut updated_key = key.clone();
|
||||
updated_key.api_formats = Some(serde_json::Value::Array(
|
||||
updated_formats
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect(),
|
||||
));
|
||||
updated_key.updated_at_unix_secs = Some(now_unix_secs);
|
||||
if state
|
||||
.update_provider_catalog_key(&updated_key)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
affected_keys_count += 1;
|
||||
}
|
||||
if !state.delete_provider_catalog_endpoint(&endpoint_id).await? {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(
|
||||
Json(json!({
|
||||
"message": format!("Endpoint {endpoint_id} 已删除"),
|
||||
"affected_keys_count": affected_keys_count,
|
||||
}))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use super::builders::build_admin_endpoint_payload;
|
||||
use super::extractors::admin_endpoint_id;
|
||||
use super::support::build_admin_endpoints_data_unavailable_response;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
_request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() != Some("endpoints_manage")
|
||||
|| decision.route_kind.as_deref() != Some("get_endpoint")
|
||||
|| !request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
|
||||
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Endpoint 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
|
||||
Ok(Some(
|
||||
match build_admin_endpoint_payload(state, &endpoint_id).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::builders::build_admin_provider_endpoints_payload;
|
||||
use super::extractors::admin_provider_id_for_endpoints;
|
||||
use super::support::build_admin_endpoints_data_unavailable_response;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
_request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() != Some("endpoints_manage")
|
||||
|| decision.route_kind.as_deref() != Some("list_provider_endpoints")
|
||||
|| !request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
|| !request_context.request_path.ends_with("/endpoints")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
|
||||
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(100);
|
||||
|
||||
Ok(Some(
|
||||
match build_admin_provider_endpoints_payload(state, &provider_id, skip, limit).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1,477 +1,48 @@
|
||||
mod builders;
|
||||
mod create;
|
||||
mod defaults;
|
||||
mod delete;
|
||||
mod detail;
|
||||
mod extractors;
|
||||
mod list;
|
||||
mod payloads;
|
||||
mod support;
|
||||
mod update;
|
||||
|
||||
use self::builders::{
|
||||
build_admin_create_provider_endpoint_record, build_admin_endpoint_payload,
|
||||
build_admin_provider_endpoints_payload, build_admin_update_provider_endpoint_record,
|
||||
};
|
||||
use self::extractors::{
|
||||
admin_default_body_rules_api_format, admin_endpoint_id, admin_provider_id_for_endpoints,
|
||||
};
|
||||
use self::payloads::endpoint_key_counts_by_format;
|
||||
use self::payloads::{
|
||||
build_admin_provider_endpoint_response, key_api_formats_without_entry,
|
||||
AdminProviderEndpointCreateRequest, AdminProviderEndpointUpdateRequest,
|
||||
};
|
||||
use crate::api::ai::admin_default_body_rules_for_signature;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL: &str = "Admin endpoint data unavailable";
|
||||
|
||||
fn build_admin_endpoints_data_unavailable_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_endpoints_routes_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("list_provider_endpoints")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
&& request_context.request_path.ends_with("/endpoints")
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path)
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let skip = query_param_value(request_context.request_query_string.as_deref(), "skip")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let limit = query_param_value(request_context.request_query_string.as_deref(), "limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(100);
|
||||
return Ok(Some(
|
||||
match build_admin_provider_endpoints_payload(state, &provider_id, skip, limit).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
if let Some(response) = create::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("create_endpoint")
|
||||
&& request_context.request_method == http::Method::POST
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/providers/")
|
||||
&& request_context.request_path.ends_with("/endpoints")
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
let Some(provider_id) = admin_provider_id_for_endpoints(&request_context.request_path)
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload =
|
||||
match serde_json::from_slice::<AdminProviderEndpointCreateRequest>(request_body) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let record =
|
||||
match build_admin_create_provider_endpoint_record(state, &provider, payload).await {
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(created) = state.create_provider_catalog_endpoint(&record).await? else {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
return Ok(Some(
|
||||
Json(build_admin_provider_endpoint_response(
|
||||
&created,
|
||||
&provider.name,
|
||||
0,
|
||||
0,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
));
|
||||
if let Some(response) = update::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("update_endpoint")
|
||||
&& request_context.request_method == http::Method::PUT
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/")
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Endpoint 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(raw_payload) = raw_value.as_object().cloned() else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload = match serde_json::from_value::<AdminProviderEndpointUpdateRequest>(raw_value)
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(existing_endpoint) = state
|
||||
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(
|
||||
&existing_endpoint.provider_id,
|
||||
))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {} 不存在", existing_endpoint.provider_id) })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let updated_record = match build_admin_update_provider_endpoint_record(
|
||||
state,
|
||||
&provider,
|
||||
&existing_endpoint,
|
||||
&raw_payload,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(updated) = state
|
||||
.update_provider_catalog_endpoint(&updated_record)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (total_keys_by_format, active_keys_by_format) = endpoint_key_counts_by_format(&keys);
|
||||
return Ok(Some(
|
||||
Json(build_admin_provider_endpoint_response(
|
||||
&updated,
|
||||
&provider.name,
|
||||
total_keys_by_format
|
||||
.get(updated.api_format.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
active_keys_by_format
|
||||
.get(updated.api_format.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
));
|
||||
if let Some(response) = delete::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("delete_endpoint")
|
||||
&& request_context.request_method == http::Method::DELETE
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/")
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Endpoint 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(existing_endpoint) = state
|
||||
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(
|
||||
&existing_endpoint.provider_id,
|
||||
))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut affected_keys_count = 0usize;
|
||||
for key in keys {
|
||||
let Some(updated_formats) =
|
||||
key_api_formats_without_entry(&key, existing_endpoint.api_format.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut updated_key = key.clone();
|
||||
updated_key.api_formats = Some(serde_json::Value::Array(
|
||||
updated_formats
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect(),
|
||||
));
|
||||
updated_key.updated_at_unix_secs = Some(now_unix_secs);
|
||||
if state
|
||||
.update_provider_catalog_key(&updated_key)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
affected_keys_count += 1;
|
||||
}
|
||||
if !state.delete_provider_catalog_endpoint(&endpoint_id).await? {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": format!("Endpoint {endpoint_id} 已删除"),
|
||||
"affected_keys_count": affected_keys_count,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
if let Some(response) = list::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("get_endpoint")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/")
|
||||
{
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Endpoint 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match build_admin_endpoint_payload(state, &endpoint_id).await {
|
||||
Some(payload) => Json(payload).into_response(),
|
||||
None => (
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
if let Some(response) = detail::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("endpoints_manage")
|
||||
&& decision.route_kind.as_deref() == Some("default_body_rules")
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/defaults/")
|
||||
&& request_context.request_path.ends_with("/body-rules")
|
||||
{
|
||||
let Some(api_format) = admin_default_body_rules_api_format(&request_context.request_path)
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "无效的 api_format" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let provider_type = query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"provider_type",
|
||||
);
|
||||
return Ok(Some(
|
||||
match admin_default_body_rules_for_signature(&api_format, provider_type.as_deref()) {
|
||||
Some((normalized_api_format, body_rules)) => Json(json!({
|
||||
"api_format": normalized_api_format,
|
||||
"body_rules": body_rules,
|
||||
}))
|
||||
.into_response(),
|
||||
None => (
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": format!("无效的 api_format: {api_format}") })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
));
|
||||
if let Some(response) = defaults::maybe_handle(state, request_context, request_body).await? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) const ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL: &str = "Admin endpoint data unavailable";
|
||||
|
||||
pub(super) fn build_admin_endpoints_data_unavailable_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": ADMIN_ENDPOINTS_DATA_UNAVAILABLE_DETAIL })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use super::builders::build_admin_update_provider_endpoint_record;
|
||||
use super::extractors::admin_endpoint_id;
|
||||
use super::payloads::{
|
||||
build_admin_provider_endpoint_response, endpoint_key_counts_by_format,
|
||||
AdminProviderEndpointUpdateRequest,
|
||||
};
|
||||
use super::support::build_admin_endpoints_data_unavailable_response;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(super) async fn maybe_handle(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if decision.route_family.as_deref() != Some("endpoints_manage")
|
||||
|| decision.route_kind.as_deref() != Some("update_endpoint")
|
||||
|| request_context.request_method != http::Method::PUT
|
||||
|| !request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/endpoints/")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
}
|
||||
|
||||
let Some(endpoint_id) = admin_endpoint_id(&request_context.request_path) else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Endpoint 不存在" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体不能为空" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let raw_value = match serde_json::from_slice::<serde_json::Value>(request_body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(raw_payload) = raw_value.as_object().cloned() else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let payload = match serde_json::from_value::<AdminProviderEndpointUpdateRequest>(raw_value) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求体必须是合法的 JSON 对象" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(existing_endpoint) = state
|
||||
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Endpoint {endpoint_id} 不存在") })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(
|
||||
&existing_endpoint.provider_id,
|
||||
))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {} 不存在", existing_endpoint.provider_id) })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let updated_record = match build_admin_update_provider_endpoint_record(
|
||||
state,
|
||||
&provider,
|
||||
&existing_endpoint,
|
||||
&raw_payload,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(detail) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(updated) = state
|
||||
.update_provider_catalog_endpoint(&updated_record)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Some(build_admin_endpoints_data_unavailable_response()));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (total_keys_by_format, active_keys_by_format) = endpoint_key_counts_by_format(&keys);
|
||||
|
||||
Ok(Some(
|
||||
Json(build_admin_provider_endpoint_response(
|
||||
&updated,
|
||||
&provider.name,
|
||||
total_keys_by_format
|
||||
.get(updated.api_format.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
active_keys_by_format
|
||||
.get(updated.api_format.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
use super::super::super::model::build_admin_batch_assign_global_models_payload;
|
||||
use super::write::build_admin_batch_assign_global_models_payload;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_provider_assign_global_models_path, AdminBatchAssignGlobalModelsRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_assign_global_models_path;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminBatchAssignGlobalModelsRequest;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::super::model::build_admin_provider_available_source_models_payload;
|
||||
use super::write::build_admin_provider_available_source_models_payload;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::admin_provider_available_source_models_path;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_available_source_models_path;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use super::super::super::model::{
|
||||
admin_provider_model_name_exists, build_admin_provider_model_create_record,
|
||||
build_admin_provider_model_response,
|
||||
};
|
||||
use super::payloads::{admin_provider_model_name_exists, build_admin_provider_model_response};
|
||||
use super::write::build_admin_provider_model_create_record;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_provider_models_batch_path, AdminProviderModelCreateRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_models_batch_path;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderModelCreateRequest;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use super::super::super::model::{
|
||||
build_admin_provider_model_create_record, build_admin_provider_model_response,
|
||||
};
|
||||
use super::payloads::build_admin_provider_model_response;
|
||||
use super::write::build_admin_provider_model_create_record;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_provider_id_for_models_list, AdminProviderModelCreateRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_id_for_models_list;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderModelCreateRequest;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::admin_provider_model_route_parts;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::super::model::build_admin_provider_model_payload;
|
||||
use super::payloads::build_admin_provider_model_payload;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::admin_provider_model_route_parts;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use super::super::super::model::build_admin_import_provider_models_payload;
|
||||
use super::write::build_admin_import_provider_models_payload;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_provider_import_models_path, AdminImportProviderModelsRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_import_models_path;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminImportProviderModelsRequest;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::super::model::build_admin_provider_models_payload;
|
||||
use super::payloads::build_admin_provider_models_payload;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::admin_provider_id_for_models_list;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_id_for_models_list;
|
||||
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
|
||||
@@ -12,7 +12,9 @@ mod delete;
|
||||
mod detail;
|
||||
mod import;
|
||||
mod list;
|
||||
mod payloads;
|
||||
mod update;
|
||||
mod write;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_provider_models_response(
|
||||
state: &AppState,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
AdminProviderModelListQuery, StoredAdminProviderModel,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn model_tiered_pricing_first_tier_value(
|
||||
tiered_pricing: Option<&serde_json::Value>,
|
||||
field_name: &str,
|
||||
) -> Option<f64> {
|
||||
tiered_pricing
|
||||
.and_then(|value| value.get("tiers"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|tiers| tiers.first())
|
||||
.and_then(|tier| tier.get(field_name))
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
}
|
||||
|
||||
fn model_effective_capability(
|
||||
explicit: Option<bool>,
|
||||
global_model_config: Option<&serde_json::Value>,
|
||||
config_key: &str,
|
||||
) -> bool {
|
||||
explicit.unwrap_or_else(|| {
|
||||
global_model_config
|
||||
.and_then(|value| value.get(config_key))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_json_values(base: &mut serde_json::Value, overlay: serde_json::Value) {
|
||||
match (base, overlay) {
|
||||
(serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
|
||||
for (key, value) in overlay_map {
|
||||
match base_map.get_mut(&key) {
|
||||
Some(existing) => merge_json_values(existing, value),
|
||||
None => {
|
||||
base_map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(base, overlay) => *base = overlay,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_admin_provider_model_effective_config(
|
||||
model: &StoredAdminProviderModel,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut merged = match model.global_model_config.clone() {
|
||||
Some(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
|
||||
Some(other) => other,
|
||||
None => serde_json::Value::Object(serde_json::Map::new()),
|
||||
};
|
||||
|
||||
if let Some(config) = model.config.clone() {
|
||||
merge_json_values(&mut merged, config);
|
||||
}
|
||||
|
||||
match merged {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::Object(ref map) if map.is_empty() => None,
|
||||
value => Some(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp_or_now(value: Option<u64>, now_unix_secs: u64) -> serde_json::Value {
|
||||
unix_secs_to_rfc3339(value.unwrap_or(now_unix_secs))
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
pub(super) fn admin_provider_model_effective_input_price(
|
||||
model: &StoredAdminProviderModel,
|
||||
) -> Option<f64> {
|
||||
model_tiered_pricing_first_tier_value(model.tiered_pricing.as_ref(), "input_price_per_1m")
|
||||
.or_else(|| {
|
||||
model_tiered_pricing_first_tier_value(
|
||||
model.global_model_default_tiered_pricing.as_ref(),
|
||||
"input_price_per_1m",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn admin_provider_model_effective_output_price(
|
||||
model: &StoredAdminProviderModel,
|
||||
) -> Option<f64> {
|
||||
model_tiered_pricing_first_tier_value(model.tiered_pricing.as_ref(), "output_price_per_1m")
|
||||
.or_else(|| {
|
||||
model_tiered_pricing_first_tier_value(
|
||||
model.global_model_default_tiered_pricing.as_ref(),
|
||||
"output_price_per_1m",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn admin_provider_model_effective_capability(
|
||||
model: &StoredAdminProviderModel,
|
||||
capability: &str,
|
||||
) -> bool {
|
||||
match capability {
|
||||
"vision" => model_effective_capability(
|
||||
model.supports_vision,
|
||||
model.global_model_config.as_ref(),
|
||||
"vision",
|
||||
),
|
||||
"function_calling" => model_effective_capability(
|
||||
model.supports_function_calling,
|
||||
model.global_model_config.as_ref(),
|
||||
"function_calling",
|
||||
),
|
||||
"streaming" => model_effective_capability(
|
||||
model.supports_streaming,
|
||||
model.global_model_config.as_ref(),
|
||||
"streaming",
|
||||
),
|
||||
"extended_thinking" => model_effective_capability(
|
||||
model.supports_extended_thinking,
|
||||
model.global_model_config.as_ref(),
|
||||
"extended_thinking",
|
||||
),
|
||||
"image_generation" => model_effective_capability(
|
||||
model.supports_image_generation,
|
||||
model.global_model_config.as_ref(),
|
||||
"image_generation",
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_provider_model_response(
|
||||
model: &StoredAdminProviderModel,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
let effective_tiered_pricing = model
|
||||
.tiered_pricing
|
||||
.clone()
|
||||
.or_else(|| model.global_model_default_tiered_pricing.clone());
|
||||
let effective_config = merge_admin_provider_model_effective_config(model);
|
||||
|
||||
json!({
|
||||
"id": &model.id,
|
||||
"provider_id": &model.provider_id,
|
||||
"global_model_id": &model.global_model_id,
|
||||
"provider_model_name": &model.provider_model_name,
|
||||
"provider_model_mappings": model.provider_model_mappings.clone(),
|
||||
"price_per_request": model.price_per_request,
|
||||
"tiered_pricing": model.tiered_pricing.clone(),
|
||||
"effective_tiered_pricing": effective_tiered_pricing,
|
||||
"effective_input_price": admin_provider_model_effective_input_price(model),
|
||||
"effective_output_price": admin_provider_model_effective_output_price(model),
|
||||
"effective_price_per_request": model
|
||||
.price_per_request
|
||||
.or(model.global_model_default_price_per_request),
|
||||
"supports_vision": model.supports_vision,
|
||||
"supports_function_calling": model.supports_function_calling,
|
||||
"supports_streaming": model.supports_streaming,
|
||||
"supports_extended_thinking": model.supports_extended_thinking,
|
||||
"supports_image_generation": model.supports_image_generation,
|
||||
"effective_supports_vision": admin_provider_model_effective_capability(model, "vision"),
|
||||
"effective_supports_function_calling": admin_provider_model_effective_capability(
|
||||
model,
|
||||
"function_calling",
|
||||
),
|
||||
"effective_supports_streaming": admin_provider_model_effective_capability(model, "streaming"),
|
||||
"effective_supports_extended_thinking": admin_provider_model_effective_capability(
|
||||
model,
|
||||
"extended_thinking",
|
||||
),
|
||||
"effective_supports_image_generation": admin_provider_model_effective_capability(
|
||||
model,
|
||||
"image_generation",
|
||||
),
|
||||
"is_active": model.is_active,
|
||||
"is_available": model.is_available,
|
||||
"config": model.config.clone(),
|
||||
"effective_config": effective_config,
|
||||
"global_model_name": model.global_model_name.clone(),
|
||||
"global_model_display_name": model.global_model_display_name.clone(),
|
||||
"created_at": timestamp_or_now(model.created_at_unix_secs, now_unix_secs),
|
||||
"updated_at": timestamp_or_now(model.updated_at_unix_secs, now_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_provider_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
skip: usize,
|
||||
limit: usize,
|
||||
is_active: Option<bool>,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() || !state.has_global_model_data_reader() {
|
||||
return None;
|
||||
}
|
||||
let provider = state
|
||||
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
||||
.await
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.next()?;
|
||||
let mut models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider.id,
|
||||
is_active,
|
||||
offset: skip,
|
||||
limit,
|
||||
})
|
||||
.await
|
||||
.ok()?;
|
||||
models.sort_by(|left, right| {
|
||||
left.provider_model_name
|
||||
.cmp(&right.provider_model_name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
Some(serde_json::Value::Array(
|
||||
models
|
||||
.iter()
|
||||
.map(|model| build_admin_provider_model_response(model, now_unix_secs))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_provider_model_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
model_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_global_model_data_reader() {
|
||||
return None;
|
||||
}
|
||||
let model = state
|
||||
.get_admin_provider_model(provider_id, model_id)
|
||||
.await
|
||||
.ok()??;
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
Some(build_admin_provider_model_response(&model, now_unix_secs))
|
||||
}
|
||||
|
||||
pub(super) async fn admin_provider_model_name_exists(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
provider_model_name: &str,
|
||||
exclude_model_id: Option<&str>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let target = provider_model_name.trim();
|
||||
if target.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider_id.to_string(),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10_000,
|
||||
})
|
||||
.await?;
|
||||
Ok(models.into_iter().any(|model| {
|
||||
model.provider_model_name == target
|
||||
&& exclude_model_id.is_none_or(|exclude| model.id != exclude)
|
||||
}))
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
use super::super::super::model::{
|
||||
build_admin_provider_model_response, build_admin_provider_model_update_record,
|
||||
};
|
||||
use super::payloads::build_admin_provider_model_response;
|
||||
use super::write::build_admin_provider_model_update_record;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_provider_model_route_parts, AdminProviderModelUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_model_route_parts;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderModelUpdateRequest;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
|
||||
472
apps/aether-gateway/src/handlers/admin/provider/models/write.rs
Normal file
472
apps/aether-gateway/src/handlers/admin/provider/models/write.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
use super::payloads::{
|
||||
admin_provider_model_effective_capability, admin_provider_model_effective_input_price,
|
||||
admin_provider_model_effective_output_price, admin_provider_model_name_exists,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminImportProviderModelsRequest, AdminProviderModelCreateRequest,
|
||||
AdminProviderModelUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::shared::{
|
||||
normalize_json_array, normalize_json_object, normalize_string_list,
|
||||
};
|
||||
use crate::AppState;
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
AdminProviderModelListQuery, CreateAdminGlobalModelRecord, UpdateAdminGlobalModelRecord,
|
||||
UpsertAdminProviderModelRecord,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn normalize_required_trimmed_string(value: &str, field_name: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{field_name} 不能为空"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_price(value: Option<f64>, field_name: &str) -> Result<Option<f64>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
return Err(format!("{field_name} 必须是非负数"));
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
async fn resolve_admin_global_model_by_id_or_err(
|
||||
state: &AppState,
|
||||
global_model_id: &str,
|
||||
) -> Result<aether_data_contracts::repository::global_models::StoredAdminGlobalModel, String> {
|
||||
state
|
||||
.get_admin_global_model_by_id(global_model_id)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
.ok_or_else(|| format!("GlobalModel {global_model_id} 不存在"))
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_provider_model_create_record(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
payload: AdminProviderModelCreateRequest,
|
||||
) -> Result<UpsertAdminProviderModelRecord, String> {
|
||||
let provider_model_name =
|
||||
normalize_required_trimmed_string(&payload.provider_model_name, "provider_model_name")?;
|
||||
if admin_provider_model_name_exists(state, provider_id, &provider_model_name, None)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
{
|
||||
return Err(format!("模型 '{provider_model_name}' 已存在"));
|
||||
}
|
||||
let global_model_id =
|
||||
normalize_required_trimmed_string(&payload.global_model_id, "global_model_id")?;
|
||||
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
|
||||
let price_per_request =
|
||||
normalize_optional_price(payload.price_per_request, "price_per_request")?;
|
||||
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
|
||||
let provider_model_mappings =
|
||||
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?;
|
||||
let config = normalize_json_object(payload.config, "config")?;
|
||||
UpsertAdminProviderModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
provider_id.to_string(),
|
||||
global_model_id,
|
||||
provider_model_name,
|
||||
provider_model_mappings,
|
||||
price_per_request,
|
||||
tiered_pricing,
|
||||
payload.supports_vision,
|
||||
payload.supports_function_calling,
|
||||
payload.supports_streaming,
|
||||
payload.supports_extended_thinking,
|
||||
None,
|
||||
payload.is_active.unwrap_or(true),
|
||||
true,
|
||||
config,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_provider_model_update_record(
|
||||
state: &AppState,
|
||||
existing: &aether_data_contracts::repository::global_models::StoredAdminProviderModel,
|
||||
raw_payload: &serde_json::Map<String, serde_json::Value>,
|
||||
payload: AdminProviderModelUpdateRequest,
|
||||
) -> Result<UpsertAdminProviderModelRecord, String> {
|
||||
let provider_model_name = if let Some(value) = raw_payload.get("provider_model_name") {
|
||||
let Some(name) = payload.provider_model_name.as_deref() else {
|
||||
return Err(if value.is_null() {
|
||||
"provider_model_name 不能为空".to_string()
|
||||
} else {
|
||||
"provider_model_name 必须是字符串".to_string()
|
||||
});
|
||||
};
|
||||
let name = normalize_required_trimmed_string(name, "provider_model_name")?;
|
||||
if admin_provider_model_name_exists(state, &existing.provider_id, &name, Some(&existing.id))
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
{
|
||||
return Err(format!("模型 '{name}' 已存在"));
|
||||
}
|
||||
name
|
||||
} else {
|
||||
existing.provider_model_name.clone()
|
||||
};
|
||||
|
||||
let global_model_id = if let Some(value) = raw_payload.get("global_model_id") {
|
||||
let Some(global_model_id) = payload.global_model_id.as_deref() else {
|
||||
return Err(if value.is_null() {
|
||||
"global_model_id 不能为空".to_string()
|
||||
} else {
|
||||
"global_model_id 必须是字符串".to_string()
|
||||
});
|
||||
};
|
||||
let global_model_id =
|
||||
normalize_required_trimmed_string(global_model_id, "global_model_id")?;
|
||||
resolve_admin_global_model_by_id_or_err(state, &global_model_id).await?;
|
||||
global_model_id
|
||||
} else {
|
||||
existing.global_model_id.clone()
|
||||
};
|
||||
|
||||
let price_per_request = if raw_payload.contains_key("price_per_request") {
|
||||
normalize_optional_price(payload.price_per_request, "price_per_request")?
|
||||
} else {
|
||||
existing.price_per_request
|
||||
};
|
||||
let tiered_pricing = if raw_payload.contains_key("tiered_pricing") {
|
||||
normalize_json_object(payload.tiered_pricing, "tiered_pricing")?
|
||||
} else {
|
||||
existing.tiered_pricing.clone()
|
||||
};
|
||||
let provider_model_mappings = if raw_payload.contains_key("provider_model_mappings") {
|
||||
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?
|
||||
} else {
|
||||
existing.provider_model_mappings.clone()
|
||||
};
|
||||
let config = if raw_payload.contains_key("config") {
|
||||
normalize_json_object(payload.config, "config")?
|
||||
} else {
|
||||
existing.config.clone()
|
||||
};
|
||||
|
||||
UpsertAdminProviderModelRecord::new(
|
||||
existing.id.clone(),
|
||||
existing.provider_id.clone(),
|
||||
global_model_id,
|
||||
provider_model_name,
|
||||
provider_model_mappings,
|
||||
price_per_request,
|
||||
tiered_pricing,
|
||||
if raw_payload.contains_key("supports_vision") {
|
||||
payload.supports_vision
|
||||
} else {
|
||||
existing.supports_vision
|
||||
},
|
||||
if raw_payload.contains_key("supports_function_calling") {
|
||||
payload.supports_function_calling
|
||||
} else {
|
||||
existing.supports_function_calling
|
||||
},
|
||||
if raw_payload.contains_key("supports_streaming") {
|
||||
payload.supports_streaming
|
||||
} else {
|
||||
existing.supports_streaming
|
||||
},
|
||||
if raw_payload.contains_key("supports_extended_thinking") {
|
||||
payload.supports_extended_thinking
|
||||
} else {
|
||||
existing.supports_extended_thinking
|
||||
},
|
||||
existing.supports_image_generation,
|
||||
payload.is_active.unwrap_or(existing.is_active),
|
||||
payload.is_available.unwrap_or(existing.is_available),
|
||||
config,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_provider_available_source_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_global_model_data_reader() || !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
let provider = state
|
||||
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
||||
.await
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.next()?;
|
||||
let models = state
|
||||
.list_admin_provider_available_source_models(&provider.id)
|
||||
.await
|
||||
.ok()?;
|
||||
let mut by_global_model = BTreeMap::<
|
||||
String,
|
||||
aether_data_contracts::repository::global_models::StoredAdminProviderModel,
|
||||
>::new();
|
||||
for model in models {
|
||||
by_global_model
|
||||
.entry(model.global_model_id.clone())
|
||||
.or_insert(model);
|
||||
}
|
||||
let mut payload_models = by_global_model
|
||||
.into_values()
|
||||
.map(|model| {
|
||||
json!({
|
||||
"global_model_name": model.global_model_name,
|
||||
"display_name": model.global_model_display_name,
|
||||
"provider_model_name": model.provider_model_name,
|
||||
"model_id": model.id,
|
||||
"price": {
|
||||
"input_price_per_1m": admin_provider_model_effective_input_price(&model),
|
||||
"output_price_per_1m": admin_provider_model_effective_output_price(&model),
|
||||
"cache_creation_price_per_1m": serde_json::Value::Null,
|
||||
"cache_read_price_per_1m": serde_json::Value::Null,
|
||||
"price_per_request": model.price_per_request.or(model.global_model_default_price_per_request),
|
||||
},
|
||||
"capabilities": json!({
|
||||
"supports_vision": admin_provider_model_effective_capability(&model, "vision"),
|
||||
"supports_function_calling": admin_provider_model_effective_capability(&model, "function_calling"),
|
||||
"supports_streaming": admin_provider_model_effective_capability(&model, "streaming"),
|
||||
}),
|
||||
"is_active": model.is_active,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let total = payload_models.len();
|
||||
payload_models.sort_by(|left, right| {
|
||||
left.get("global_model_name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.cmp(
|
||||
&right
|
||||
.get("global_model_name")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
)
|
||||
});
|
||||
Some(json!({
|
||||
"models": payload_models,
|
||||
"total": total,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_batch_assign_global_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
global_model_ids: Vec<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let existing_models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider_id.to_string(),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10_000,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let existing_global_model_ids = existing_models
|
||||
.into_iter()
|
||||
.map(|model| model.global_model_id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
let mut success = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for global_model_id in global_model_ids {
|
||||
let global_model_id = global_model_id.trim().to_string();
|
||||
if global_model_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let global_model =
|
||||
match resolve_admin_global_model_by_id_or_err(state, &global_model_id).await {
|
||||
Ok(model) => model,
|
||||
Err(detail) => {
|
||||
errors.push(json!({
|
||||
"global_model_id": global_model_id,
|
||||
"error": detail,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if existing_global_model_ids.contains(&global_model.id) {
|
||||
errors.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"error": "Model already exists",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let record = UpsertAdminProviderModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
provider_id.to_string(),
|
||||
global_model.id.clone(),
|
||||
global_model.name.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
match state.create_admin_provider_model(&record).await {
|
||||
Ok(Some(created)) => success.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"global_model_name": global_model.name,
|
||||
"provider_model_id": created.id,
|
||||
})),
|
||||
Ok(None) => errors.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"error": "Create provider model failed",
|
||||
})),
|
||||
Err(err) => errors.push(json!({
|
||||
"global_model_id": global_model.id,
|
||||
"error": format!("{err:?}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"success": success,
|
||||
"errors": errors,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_import_provider_models_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
payload: AdminImportProviderModelsRequest,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let default_pricing = json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 0.0,
|
||||
"output_price_per_1m": 0.0,
|
||||
}]
|
||||
});
|
||||
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
|
||||
|
||||
let existing_models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider_id.to_string(),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10_000,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let mut existing_by_name = existing_models
|
||||
.iter()
|
||||
.map(|model| (model.provider_model_name.clone(), model.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let mut success = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for model_id in payload.model_ids {
|
||||
let trimmed = model_id.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 100 {
|
||||
errors.push(json!({
|
||||
"model_id": if trimmed.is_empty() { "<empty>" } else { trimmed },
|
||||
"error": "Invalid model_id: must be 1-100 characters",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = existing_by_name.get(trimmed) {
|
||||
success.push(json!({
|
||||
"model_id": trimmed,
|
||||
"global_model_id": existing.global_model_id,
|
||||
"global_model_name": existing.global_model_name,
|
||||
"provider_model_id": existing.id,
|
||||
"created_global_model": false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut created_global_model = false;
|
||||
let global_model = if let Some(existing) = state
|
||||
.get_admin_global_model_by_name(trimmed)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
{
|
||||
existing
|
||||
} else {
|
||||
let created = state
|
||||
.create_admin_global_model(
|
||||
&CreateAdminGlobalModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
trimmed.to_string(),
|
||||
trimmed.to_string(),
|
||||
true,
|
||||
payload.price_per_request,
|
||||
tiered_pricing
|
||||
.clone()
|
||||
.or_else(|| Some(default_pricing.clone())),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|err| err.to_string())?,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let Some(created) = created else {
|
||||
errors.push(json!({"model_id": trimmed, "error": "Create GlobalModel failed"}));
|
||||
continue;
|
||||
};
|
||||
created_global_model = true;
|
||||
created
|
||||
};
|
||||
|
||||
let record = UpsertAdminProviderModelRecord::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
provider_id.to_string(),
|
||||
global_model.id.clone(),
|
||||
trimmed.to_string(),
|
||||
None,
|
||||
payload.price_per_request,
|
||||
tiered_pricing.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
match state.create_admin_provider_model(&record).await {
|
||||
Ok(Some(created)) => {
|
||||
existing_by_name.insert(trimmed.to_string(), created.clone());
|
||||
success.push(json!({
|
||||
"model_id": trimmed,
|
||||
"global_model_id": global_model.id,
|
||||
"global_model_name": global_model.name,
|
||||
"provider_model_id": created.id,
|
||||
"created_global_model": created_global_model,
|
||||
}));
|
||||
}
|
||||
Ok(None) => errors.push(json!({
|
||||
"model_id": trimmed,
|
||||
"error": "Create provider model failed",
|
||||
})),
|
||||
Err(err) => errors.push(json!({
|
||||
"model_id": trimmed,
|
||||
"error": format!("{err:?}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"success": success,
|
||||
"errors": errors,
|
||||
}))
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
use super::super::provider_oauth_refresh::{
|
||||
use super::super::refresh::{
|
||||
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
|
||||
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
|
||||
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
|
||||
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
|
||||
};
|
||||
use super::super::provider_oauth_state::{
|
||||
use super::super::state::{
|
||||
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
||||
current_unix_secs, decode_jwt_claims, exchange_admin_provider_oauth_refresh_token,
|
||||
is_fixed_provider_type_for_provider_oauth, save_provider_oauth_batch_task_payload,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_oauth_batch_import_provider_id,
|
||||
admin_provider_oauth_batch_import_task_provider_id,
|
||||
ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{to_bytes, Body, Bytes},
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
use super::super::provider_oauth_quota::refresh_codex_provider_quota_locally;
|
||||
use super::super::provider_oauth_refresh::{
|
||||
use super::super::quota::codex::refresh_codex_provider_quota_locally;
|
||||
use super::super::refresh::{
|
||||
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
|
||||
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
|
||||
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
|
||||
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
|
||||
};
|
||||
use super::super::provider_oauth_state::{
|
||||
use super::super::state::{
|
||||
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
||||
consume_provider_oauth_state, enrich_admin_provider_oauth_auth_config,
|
||||
exchange_admin_provider_oauth_code, is_fixed_provider_type_for_provider_oauth,
|
||||
json_non_empty_string, json_u64_value, parse_provider_oauth_callback_params,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_oauth_complete_key_id, admin_provider_oauth_complete_provider_id,
|
||||
};
|
||||
use crate::handlers::admin::shared::encrypt_catalog_secret_with_fallbacks;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::super::provider_oauth_refresh::{
|
||||
use super::super::refresh::{
|
||||
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
|
||||
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
|
||||
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
|
||||
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
|
||||
};
|
||||
use super::super::provider_oauth_state::{
|
||||
use super::super::state::{
|
||||
build_admin_provider_oauth_backend_unavailable_response, build_kiro_device_key_name,
|
||||
current_unix_secs, decode_jwt_claims, default_kiro_device_region,
|
||||
default_kiro_device_start_url, generate_provider_oauth_nonce, json_non_empty_string,
|
||||
@@ -13,7 +13,7 @@ use super::super::provider_oauth_state::{
|
||||
save_provider_oauth_device_session, start_admin_kiro_device_authorization,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_oauth_device_authorize_provider_id, admin_provider_oauth_device_poll_provider_id,
|
||||
};
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
pub(super) fn attach_admin_provider_oauth_audit_response(
|
||||
response: Response<Body>,
|
||||
event_name: &'static str,
|
||||
action: &'static str,
|
||||
target_type: &'static str,
|
||||
target_id: Option<String>,
|
||||
) -> Response<Body> {
|
||||
if !response.status().is_success() {
|
||||
return response;
|
||||
}
|
||||
let Some(target_id) = target_id else {
|
||||
return response;
|
||||
};
|
||||
attach_admin_audit_response(response, event_name, action, target_type, &target_id)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
use super::super::provider_oauth_refresh::{
|
||||
use super::super::refresh::{
|
||||
build_internal_control_error_response, build_provider_oauth_auth_config_from_token_payload,
|
||||
create_provider_oauth_catalog_key, find_duplicate_provider_oauth_key,
|
||||
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
|
||||
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
|
||||
};
|
||||
use super::super::provider_oauth_state::{
|
||||
use super::super::state::{
|
||||
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
||||
exchange_admin_provider_oauth_refresh_token, is_fixed_provider_type_for_provider_oauth,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::admin_provider_oauth_import_provider_id;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_import_provider_id;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::Body,
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
use super::provider_oauth_state::{
|
||||
use super::state::{
|
||||
build_admin_provider_oauth_backend_unavailable_response,
|
||||
build_admin_provider_oauth_supported_types_payload,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_oauth_batch_import_provider_id,
|
||||
admin_provider_oauth_batch_import_task_provider_id, admin_provider_oauth_complete_key_id,
|
||||
admin_provider_oauth_complete_provider_id, admin_provider_oauth_device_authorize_provider_id,
|
||||
admin_provider_oauth_import_provider_id, admin_provider_oauth_refresh_key_id,
|
||||
admin_provider_oauth_start_key_id, admin_provider_oauth_start_provider_id,
|
||||
};
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
@@ -22,6 +21,7 @@ use axum::{
|
||||
mod batch;
|
||||
mod complete;
|
||||
mod device;
|
||||
mod helpers;
|
||||
mod import;
|
||||
mod refresh;
|
||||
mod start;
|
||||
@@ -53,7 +53,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
|
||||
if route_kind == Some("start_key_oauth") && *method == http::Method::POST {
|
||||
let response = start::handle_admin_provider_oauth_start_key(state, request_context).await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_authorization_started",
|
||||
"start_provider_oauth_for_key",
|
||||
@@ -65,7 +65,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
if route_kind == Some("start_provider_oauth") && *method == http::Method::POST {
|
||||
let response =
|
||||
start::handle_admin_provider_oauth_start_provider(state, request_context).await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_authorization_started",
|
||||
"start_provider_oauth_for_provider",
|
||||
@@ -88,7 +88,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
request_body,
|
||||
)
|
||||
.await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_completed",
|
||||
"complete_provider_oauth_for_key",
|
||||
@@ -100,7 +100,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
if route_kind == Some("refresh_key_oauth") && *method == http::Method::POST {
|
||||
let response =
|
||||
refresh::handle_admin_provider_oauth_refresh_key(state, request_context).await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_refreshed",
|
||||
"refresh_provider_oauth_for_key",
|
||||
@@ -116,7 +116,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
request_body,
|
||||
)
|
||||
.await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_completed",
|
||||
"complete_provider_oauth_for_provider",
|
||||
@@ -132,7 +132,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
request_body,
|
||||
)
|
||||
.await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_refresh_token_imported",
|
||||
"import_provider_oauth_refresh_token",
|
||||
@@ -145,7 +145,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
let response =
|
||||
batch::handle_admin_provider_oauth_batch_import(state, request_context, request_body)
|
||||
.await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_batch_import_completed",
|
||||
"batch_import_provider_oauth",
|
||||
@@ -161,7 +161,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
request_body,
|
||||
)
|
||||
.await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_batch_import_started",
|
||||
"start_provider_oauth_batch_import",
|
||||
@@ -177,7 +177,7 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
request_body,
|
||||
)
|
||||
.await?;
|
||||
return Ok(Some(attach_admin_provider_oauth_audit_response(
|
||||
return Ok(Some(helpers::attach_admin_provider_oauth_audit_response(
|
||||
response,
|
||||
"admin_provider_oauth_device_authorization_started",
|
||||
"start_provider_oauth_device_authorization",
|
||||
@@ -205,18 +205,4 @@ pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn attach_admin_provider_oauth_audit_response(
|
||||
response: Response<Body>,
|
||||
event_name: &'static str,
|
||||
action: &'static str,
|
||||
target_type: &'static str,
|
||||
target_id: Option<String>,
|
||||
) -> Response<Body> {
|
||||
if !response.status().is_success() {
|
||||
return response;
|
||||
}
|
||||
let Some(target_id) = target_id else {
|
||||
return response;
|
||||
};
|
||||
attach_admin_audit_response(response, event_name, action, target_type, &target_id)
|
||||
}
|
||||
// Dispatch-specific helpers have moved to helpers.rs, so nothing remains here.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use super::super::provider_oauth_quota::persist_provider_quota_refresh_state;
|
||||
use super::super::provider_oauth_refresh::{
|
||||
use super::super::quota::shared::persist_provider_quota_refresh_state;
|
||||
use super::super::refresh::{
|
||||
build_internal_control_error_response, merge_provider_oauth_refresh_failure_reason,
|
||||
normalize_provider_oauth_refresh_error_message, provider_oauth_runtime_endpoint_for_provider,
|
||||
refresh_provider_oauth_account_state_after_update,
|
||||
};
|
||||
use super::super::provider_oauth_state::is_fixed_provider_type_for_provider_oauth;
|
||||
use super::super::state::is_fixed_provider_type_for_provider_oauth;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_provider_oauth_refresh_key_id, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_refresh_key_id;
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
};
|
||||
use crate::handlers::admin::shared::decrypt_catalog_secret_with_fallbacks;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::super::provider_oauth_refresh::build_internal_control_error_response;
|
||||
use super::super::provider_oauth_state::{
|
||||
use super::super::refresh::build_internal_control_error_response;
|
||||
use super::super::state::{
|
||||
admin_provider_oauth_template, build_provider_oauth_start_response,
|
||||
generate_provider_oauth_pkce_verifier, is_fixed_provider_type_for_provider_oauth,
|
||||
provider_oauth_pkce_s256, save_provider_oauth_state,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_oauth_start_key_id, admin_provider_oauth_start_provider_id,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::provider_oauth_refresh::build_internal_control_error_response;
|
||||
use super::super::provider_oauth_state::read_provider_oauth_batch_task_payload;
|
||||
use super::super::refresh::build_internal_control_error_response;
|
||||
use super::super::state::read_provider_oauth_batch_task_payload;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::admin_provider_oauth_batch_import_task_path;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_batch_import_task_path;
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
|
||||
@@ -4,13 +4,3 @@ pub(crate) mod refresh;
|
||||
pub(crate) mod state;
|
||||
|
||||
pub(crate) use self::dispatch::maybe_build_local_admin_provider_oauth_response;
|
||||
pub(crate) use self::quota as provider_oauth_quota;
|
||||
pub(crate) use self::quota::{
|
||||
normalize_string_id_list, refresh_antigravity_provider_quota_locally,
|
||||
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
|
||||
};
|
||||
pub(crate) use self::refresh as provider_oauth_refresh;
|
||||
pub(crate) use self::refresh::{
|
||||
build_internal_control_error_response, normalize_provider_oauth_refresh_error_message,
|
||||
};
|
||||
pub(crate) use self::state as provider_oauth_state;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::{
|
||||
use super::shared::{
|
||||
coerce_json_f64, coerce_json_string, execute_provider_quota_plan,
|
||||
extract_execution_error_message, persist_provider_quota_refresh_state,
|
||||
quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
|
||||
use crate::handlers::admin::provider::shared::payloads::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
use super::shared::{
|
||||
coerce_json_bool, coerce_json_f64, coerce_json_string, coerce_json_u64,
|
||||
execute_provider_quota_plan, extract_execution_error_message, normalize_string_id_list,
|
||||
persist_provider_quota_refresh_state, provider_auto_remove_banned_keys,
|
||||
quota_refresh_success_invalid_state, should_auto_remove_structured_reason,
|
||||
ProviderQuotaExecutionOutcome,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
CODEX_WHAM_USAGE_URL, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use super::{
|
||||
use super::shared::{
|
||||
coerce_json_f64, execute_provider_quota_plan, extract_execution_error_message,
|
||||
persist_provider_quota_refresh_state, quota_refresh_success_invalid_state,
|
||||
ProviderQuotaExecutionOutcome,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::{KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION};
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
|
||||
};
|
||||
use crate::handlers::admin::shared::encrypt_catalog_secret_with_fallbacks;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
mod antigravity;
|
||||
mod codex;
|
||||
mod kiro;
|
||||
mod shared;
|
||||
|
||||
pub(crate) use self::antigravity::refresh_antigravity_provider_quota_locally;
|
||||
pub(crate) use self::codex::refresh_codex_provider_quota_locally;
|
||||
pub(crate) use self::kiro::refresh_kiro_provider_quota_locally;
|
||||
use self::shared::{
|
||||
coerce_json_bool, coerce_json_f64, coerce_json_string, coerce_json_u64,
|
||||
execute_provider_quota_plan, extract_execution_error_message, provider_auto_remove_banned_keys,
|
||||
quota_refresh_success_invalid_state, should_auto_remove_structured_reason,
|
||||
ProviderQuotaExecutionOutcome,
|
||||
};
|
||||
pub(crate) use self::shared::{normalize_string_id_list, persist_provider_quota_refresh_state};
|
||||
pub(crate) mod antigravity;
|
||||
pub(crate) mod codex;
|
||||
pub(crate) mod kiro;
|
||||
pub(crate) mod shared;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::provider_oauth_quota::{
|
||||
persist_provider_quota_refresh_state, refresh_antigravity_provider_quota_locally,
|
||||
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
|
||||
};
|
||||
use super::provider_oauth_state::{
|
||||
use super::quota::antigravity::refresh_antigravity_provider_quota_locally;
|
||||
use super::quota::codex::refresh_codex_provider_quota_locally;
|
||||
use super::quota::kiro::refresh_kiro_provider_quota_locally;
|
||||
use super::quota::shared::persist_provider_quota_refresh_state;
|
||||
use super::state::{
|
||||
enrich_admin_provider_oauth_auth_config, json_non_empty_string, json_u64_value,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
use super::refresh::{
|
||||
build_internal_control_error_response, normalize_provider_oauth_refresh_error_message,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
|
||||
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
|
||||
use crate::provider_transport::provider_types::{
|
||||
provider_type_admin_oauth_template, provider_type_is_fixed_for_admin_oauth,
|
||||
ProviderOAuthTemplate, ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_ops_architecture_id_from_path, is_admin_provider_ops_architectures_root,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
|
||||
@@ -4,9 +4,7 @@ use axum::body::{Body, Bytes};
|
||||
use axum::http::Response;
|
||||
|
||||
mod architectures;
|
||||
mod providers;
|
||||
|
||||
pub(crate) use self::providers::admin_provider_ops_local_action_response;
|
||||
pub(crate) mod providers;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_provider_ops_response(
|
||||
state: &AppState,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::{
|
||||
use super::config::{
|
||||
admin_provider_ops_config_object, admin_provider_ops_connector_object,
|
||||
admin_provider_ops_decrypted_credentials, admin_provider_ops_uses_python_verify_fallback,
|
||||
admin_provider_ops_value_as_f64, admin_provider_ops_verify_headers,
|
||||
resolve_admin_provider_ops_base_url, AdminProviderOpsCheckinOutcome,
|
||||
ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE,
|
||||
resolve_admin_provider_ops_base_url,
|
||||
};
|
||||
use super::support::{AdminProviderOpsCheckinOutcome, ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE};
|
||||
use super::verify::{admin_provider_ops_value_as_f64, admin_provider_ops_verify_headers};
|
||||
use crate::AppState;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS};
|
||||
use super::support::{AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS};
|
||||
use crate::handlers::admin::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks,
|
||||
};
|
||||
|
||||
@@ -1,117 +1,6 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
admin_provider_id_for_provider_ops_balance, admin_provider_id_for_provider_ops_checkin,
|
||||
admin_provider_id_for_provider_ops_config, admin_provider_id_for_provider_ops_connect,
|
||||
admin_provider_id_for_provider_ops_disconnect, admin_provider_id_for_provider_ops_status,
|
||||
admin_provider_id_for_provider_ops_verify, admin_provider_ops_action_route_parts,
|
||||
};
|
||||
use crate::handlers::admin::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
mod actions;
|
||||
pub(crate) mod actions;
|
||||
mod config;
|
||||
mod routes;
|
||||
mod support;
|
||||
mod verify;
|
||||
use self::actions::admin_provider_ops_is_valid_action_type;
|
||||
pub(crate) use self::actions::admin_provider_ops_local_action_response;
|
||||
use self::config::{
|
||||
admin_provider_ops_config_object, admin_provider_ops_connector_object,
|
||||
admin_provider_ops_decrypted_credentials, admin_provider_ops_merge_credentials,
|
||||
admin_provider_ops_uses_python_verify_fallback, build_admin_provider_ops_config_payload,
|
||||
build_admin_provider_ops_saved_config_value, build_admin_provider_ops_status_payload,
|
||||
resolve_admin_provider_ops_base_url,
|
||||
};
|
||||
pub(super) use self::routes::maybe_build_local_admin_provider_ops_providers_response;
|
||||
use self::verify::{
|
||||
admin_provider_ops_local_verify_response, admin_provider_ops_normalized_verify_architecture_id,
|
||||
admin_provider_ops_value_as_f64, admin_provider_ops_verify_failure,
|
||||
admin_provider_ops_verify_headers,
|
||||
};
|
||||
|
||||
const ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS: &[&str] = &[
|
||||
"api_key",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"session_token",
|
||||
"session_cookie",
|
||||
"token_cookie",
|
||||
"auth_cookie",
|
||||
"cookie_string",
|
||||
"cookie",
|
||||
];
|
||||
const ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE: &str =
|
||||
"Provider 连接仅支持 Rust execution runtime";
|
||||
const ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE: &str =
|
||||
"Provider 操作仅支持 Rust execution runtime";
|
||||
const ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE: &str = "认证验证仅支持 Rust execution runtime";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminProviderOpsSaveConfigRequest {
|
||||
#[serde(default = "default_admin_provider_ops_architecture_id")]
|
||||
architecture_id: String,
|
||||
#[serde(default)]
|
||||
base_url: Option<String>,
|
||||
connector: AdminProviderOpsConnectorConfigRequest,
|
||||
#[serde(default)]
|
||||
actions: BTreeMap<String, AdminProviderOpsActionConfigRequest>,
|
||||
#[serde(default)]
|
||||
schedule: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminProviderOpsConnectorConfigRequest {
|
||||
auth_type: String,
|
||||
#[serde(default)]
|
||||
config: serde_json::Map<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
credentials: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminProviderOpsActionConfigRequest {
|
||||
#[serde(default = "default_admin_provider_ops_action_enabled")]
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
config: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminProviderOpsConnectRequest {
|
||||
#[serde(default)]
|
||||
credentials: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminProviderOpsExecuteActionRequest {
|
||||
#[serde(default)]
|
||||
config: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AdminProviderOpsCheckinOutcome {
|
||||
success: Option<bool>,
|
||||
message: String,
|
||||
cookie_expired: bool,
|
||||
}
|
||||
|
||||
fn default_admin_provider_ops_architecture_id() -> String {
|
||||
"generic_api".to_string()
|
||||
}
|
||||
|
||||
fn default_admin_provider_ops_action_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use super::{
|
||||
use super::actions::{
|
||||
admin_provider_ops_is_valid_action_type, admin_provider_ops_local_action_response,
|
||||
};
|
||||
use super::config::{
|
||||
admin_provider_ops_config_object, admin_provider_ops_connector_object,
|
||||
admin_provider_ops_decrypted_credentials, admin_provider_ops_is_valid_action_type,
|
||||
admin_provider_ops_local_action_response, admin_provider_ops_local_verify_response,
|
||||
admin_provider_ops_merge_credentials, admin_provider_ops_normalized_verify_architecture_id,
|
||||
admin_provider_ops_verify_failure, build_admin_provider_ops_config_payload,
|
||||
build_admin_provider_ops_saved_config_value, build_admin_provider_ops_status_payload,
|
||||
resolve_admin_provider_ops_base_url, AdminProviderOpsConnectRequest,
|
||||
AdminProviderOpsExecuteActionRequest, AdminProviderOpsSaveConfigRequest,
|
||||
ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE,
|
||||
admin_provider_ops_decrypted_credentials, admin_provider_ops_merge_credentials,
|
||||
build_admin_provider_ops_config_payload, build_admin_provider_ops_saved_config_value,
|
||||
build_admin_provider_ops_status_payload, resolve_admin_provider_ops_base_url,
|
||||
};
|
||||
use super::support::{
|
||||
AdminProviderOpsConnectRequest, AdminProviderOpsExecuteActionRequest,
|
||||
AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE,
|
||||
};
|
||||
use super::verify::{
|
||||
admin_provider_ops_local_verify_response, admin_provider_ops_normalized_verify_architecture_id,
|
||||
admin_provider_ops_verify_failure,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_id_for_provider_ops_balance, admin_provider_id_for_provider_ops_checkin,
|
||||
admin_provider_id_for_provider_ops_config, admin_provider_id_for_provider_ops_connect,
|
||||
admin_provider_id_for_provider_ops_disconnect, admin_provider_id_for_provider_ops_status,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) const ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS: &[&str] = &[
|
||||
"api_key",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"session_token",
|
||||
"session_cookie",
|
||||
"token_cookie",
|
||||
"auth_cookie",
|
||||
"cookie_string",
|
||||
"cookie",
|
||||
];
|
||||
pub(super) const ADMIN_PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE: &str =
|
||||
"Provider 连接仅支持 Rust execution runtime";
|
||||
pub(super) const ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE: &str =
|
||||
"Provider 操作仅支持 Rust execution runtime";
|
||||
pub(super) const ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE: &str =
|
||||
"认证验证仅支持 Rust execution runtime";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AdminProviderOpsSaveConfigRequest {
|
||||
#[serde(default = "default_admin_provider_ops_architecture_id")]
|
||||
pub(crate) architecture_id: String,
|
||||
#[serde(default)]
|
||||
pub(crate) base_url: Option<String>,
|
||||
pub(crate) connector: AdminProviderOpsConnectorConfigRequest,
|
||||
#[serde(default)]
|
||||
pub(crate) actions: BTreeMap<String, AdminProviderOpsActionConfigRequest>,
|
||||
#[serde(default)]
|
||||
pub(crate) schedule: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AdminProviderOpsConnectorConfigRequest {
|
||||
pub(crate) auth_type: String,
|
||||
#[serde(default)]
|
||||
pub(crate) config: serde_json::Map<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub(crate) credentials: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AdminProviderOpsActionConfigRequest {
|
||||
#[serde(default = "default_admin_provider_ops_action_enabled")]
|
||||
pub(crate) enabled: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) config: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AdminProviderOpsConnectRequest {
|
||||
#[serde(default)]
|
||||
pub(crate) credentials: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AdminProviderOpsExecuteActionRequest {
|
||||
#[serde(default)]
|
||||
pub(crate) config: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct AdminProviderOpsCheckinOutcome {
|
||||
pub(crate) success: Option<bool>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) cookie_expired: bool,
|
||||
}
|
||||
|
||||
fn default_admin_provider_ops_architecture_id() -> String {
|
||||
"generic_api".to_string()
|
||||
}
|
||||
|
||||
fn default_admin_provider_ops_action_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE;
|
||||
use super::support::ADMIN_PROVIDER_OPS_VERIFY_RUST_ONLY_MESSAGE;
|
||||
use crate::AppState;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use regex::Regex;
|
||||
|
||||
@@ -1,485 +1,2 @@
|
||||
use super::shared::{
|
||||
AdminProviderPoolConfig, AdminProviderPoolRuntimeState, ADMIN_PROVIDER_POOL_SCAN_BATCH,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data::redis::{RedisKeyspace, RedisKvRunner};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::warn;
|
||||
|
||||
fn json_u64(value: &serde_json::Value) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_i64().and_then(|raw| u64::try_from(raw).ok()))
|
||||
}
|
||||
|
||||
fn admin_provider_pool_lru_enabled(
|
||||
raw_pool_advanced: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> bool {
|
||||
if let Some(explicit) = raw_pool_advanced
|
||||
.get("lru_enabled")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
{
|
||||
return explicit;
|
||||
}
|
||||
|
||||
let Some(presets) = raw_pool_advanced
|
||||
.get("scheduling_presets")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(first) = presets.first() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if first.is_string() {
|
||||
return raw_pool_advanced
|
||||
.get("lru_enabled")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
|
||||
presets
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_object)
|
||||
.any(|item| {
|
||||
item.get("preset")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|preset| preset.eq_ignore_ascii_case("lru"))
|
||||
&& item
|
||||
.get("enabled")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn admin_provider_pool_config(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
) -> Option<AdminProviderPoolConfig> {
|
||||
let raw_pool_advanced = provider
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|config| config.get("pool_advanced"))?;
|
||||
|
||||
let Some(pool_advanced) = raw_pool_advanced.as_object() else {
|
||||
return Some(AdminProviderPoolConfig {
|
||||
lru_enabled: false,
|
||||
cost_window_seconds: 18_000,
|
||||
cost_limit_per_key_tokens: None,
|
||||
});
|
||||
};
|
||||
|
||||
Some(AdminProviderPoolConfig {
|
||||
lru_enabled: admin_provider_pool_lru_enabled(pool_advanced),
|
||||
cost_window_seconds: pool_advanced
|
||||
.get("cost_window_seconds")
|
||||
.and_then(json_u64)
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(18_000),
|
||||
cost_limit_per_key_tokens: pool_advanced
|
||||
.get("cost_limit_per_key_tokens")
|
||||
.and_then(json_u64),
|
||||
})
|
||||
}
|
||||
|
||||
fn pool_sticky_pattern(keyspace: &RedisKeyspace, provider_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:sticky:*"))
|
||||
}
|
||||
|
||||
fn pool_lru_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:lru"))
|
||||
}
|
||||
|
||||
fn pool_cooldown_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:cooldown:{key_id}"))
|
||||
}
|
||||
|
||||
fn pool_cooldown_index_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:cooldown_idx"))
|
||||
}
|
||||
|
||||
fn pool_cost_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:cost:{key_id}"))
|
||||
}
|
||||
|
||||
fn parse_pool_cost_member(member: &str) -> u64 {
|
||||
member
|
||||
.rsplit_once(':')
|
||||
.and_then(|(_, suffix)| suffix.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn scan_redis_keys(
|
||||
connection: &mut redis::aio::MultiplexedConnection,
|
||||
pattern: &str,
|
||||
) -> Result<Vec<String>, GatewayError> {
|
||||
let mut cursor = 0u64;
|
||||
let mut keys = Vec::new();
|
||||
loop {
|
||||
let (next_cursor, batch): (u64, Vec<String>) = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(pattern)
|
||||
.arg("COUNT")
|
||||
.arg(ADMIN_PROVIDER_POOL_SCAN_BATCH)
|
||||
.query_async(connection)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
keys.extend(batch);
|
||||
if next_cursor == 0 {
|
||||
break;
|
||||
}
|
||||
cursor = next_cursor;
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn pool_cooldown_keys(
|
||||
keyspace: &RedisKeyspace,
|
||||
provider_id: &str,
|
||||
key_ids: &[String],
|
||||
) -> Vec<String> {
|
||||
key_ids
|
||||
.iter()
|
||||
.map(|key_id| pool_cooldown_key(keyspace, provider_id, key_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pool_cost_keys(keyspace: &RedisKeyspace, provider_id: &str, key_ids: &[String]) -> Vec<String> {
|
||||
key_ids
|
||||
.iter()
|
||||
.map(|key_id| pool_cost_key(keyspace, provider_id, key_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_cooldown_counts(
|
||||
runner: &RedisKvRunner,
|
||||
provider_ids: &[String],
|
||||
) -> BTreeMap<String, usize> {
|
||||
if provider_ids.is_empty() {
|
||||
return BTreeMap::new();
|
||||
}
|
||||
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for cooldown counts");
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
let mut pipeline = redis::pipe();
|
||||
for provider_id in provider_ids {
|
||||
pipeline
|
||||
.cmd("SCARD")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id));
|
||||
}
|
||||
|
||||
match pipeline.query_async::<Vec<u64>>(&mut connection).await {
|
||||
Ok(counts) => provider_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.zip(counts.into_iter())
|
||||
.map(|(provider_id, count)| (provider_id, count as usize))
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cooldown counts: {:?}",
|
||||
err
|
||||
);
|
||||
BTreeMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_runtime_state(
|
||||
runner: &RedisKvRunner,
|
||||
provider_id: &str,
|
||||
key_ids: &[String],
|
||||
pool_config: AdminProviderPoolConfig,
|
||||
) -> AdminProviderPoolRuntimeState {
|
||||
let mut runtime = AdminProviderPoolRuntimeState::default();
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
|
||||
return runtime;
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
let cooldown_keys = pool_cooldown_keys(&keyspace, provider_id, key_ids);
|
||||
let cost_keys = pool_cost_keys(&keyspace, provider_id, key_ids);
|
||||
|
||||
let sticky_keys = match scan_redis_keys(
|
||||
&mut connection,
|
||||
&pool_sticky_pattern(&keyspace, provider_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(keys) => keys,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to scan sticky keys for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
runtime.total_sticky_sessions = sticky_keys.len();
|
||||
if !sticky_keys.is_empty() {
|
||||
for chunk in sticky_keys.chunks(ADMIN_PROVIDER_POOL_SCAN_BATCH as usize) {
|
||||
let values = redis::cmd("MGET")
|
||||
.arg(chunk)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await;
|
||||
let Ok(values) = values else {
|
||||
warn!("gateway admin provider pool: failed to read sticky bindings for provider {provider_id}");
|
||||
break;
|
||||
};
|
||||
for bound_key_id in values.into_iter().flatten() {
|
||||
*runtime
|
||||
.sticky_sessions_by_key
|
||||
.entry(bound_key_id)
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cooldown_keys.is_empty() {
|
||||
let cooldown_reasons = redis::cmd("MGET")
|
||||
.arg(&cooldown_keys)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cooldown reasons for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
vec![None; cooldown_keys.len()]
|
||||
});
|
||||
let mut ttl_pipeline = redis::pipe();
|
||||
for cooldown_key in &cooldown_keys {
|
||||
ttl_pipeline.cmd("TTL").arg(cooldown_key);
|
||||
}
|
||||
let cooldown_ttls = ttl_pipeline
|
||||
.query_async::<Vec<i64>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cooldown ttl for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
vec![-1; cooldown_keys.len()]
|
||||
});
|
||||
|
||||
for (((key_id, _cooldown_key), reason), ttl) in key_ids
|
||||
.iter()
|
||||
.zip(cooldown_keys.iter())
|
||||
.zip(cooldown_reasons.into_iter())
|
||||
.zip(cooldown_ttls.into_iter())
|
||||
{
|
||||
if let Some(reason) = reason {
|
||||
runtime
|
||||
.cooldown_reason_by_key
|
||||
.insert(key_id.clone(), reason);
|
||||
if let Ok(ttl_seconds) = u64::try_from(ttl) {
|
||||
if ttl_seconds > 0 {
|
||||
runtime
|
||||
.cooldown_ttl_by_key
|
||||
.insert(key_id.clone(), ttl_seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cost_keys.is_empty() {
|
||||
let window_start = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.saturating_sub(pool_config.cost_window_seconds);
|
||||
let mut cost_pipeline = redis::pipe();
|
||||
for cost_key in &cost_keys {
|
||||
cost_pipeline
|
||||
.cmd("ZRANGEBYSCORE")
|
||||
.arg(cost_key)
|
||||
.arg(window_start)
|
||||
.arg("+inf");
|
||||
}
|
||||
let members_by_key = cost_pipeline
|
||||
.query_async::<Vec<Vec<String>>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cost windows for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
vec![Vec::new(); cost_keys.len()]
|
||||
});
|
||||
for (key_id, members) in key_ids.iter().zip(members_by_key.into_iter()) {
|
||||
let total = members
|
||||
.iter()
|
||||
.map(|member| parse_pool_cost_member(member))
|
||||
.sum::<u64>();
|
||||
runtime
|
||||
.cost_window_usage_by_key
|
||||
.insert(key_id.clone(), total);
|
||||
}
|
||||
}
|
||||
|
||||
if pool_config.lru_enabled && !key_ids.is_empty() {
|
||||
let mut command = redis::cmd("ZMSCORE");
|
||||
command.arg(pool_lru_key(&keyspace, provider_id));
|
||||
for key_id in key_ids {
|
||||
command.arg(key_id);
|
||||
}
|
||||
if let Ok(scores) = command
|
||||
.query_async::<Vec<Option<f64>>>(&mut connection)
|
||||
.await
|
||||
{
|
||||
for (key_id, score) in key_ids.iter().zip(scores.into_iter()) {
|
||||
if let Some(score) = score {
|
||||
runtime.lru_score_by_key.insert(key_id.clone(), score);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_cooldown_count(
|
||||
runner: &RedisKvRunner,
|
||||
provider_id: &str,
|
||||
) -> usize {
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
|
||||
return 0;
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
redis::cmd("SCARD")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id))
|
||||
.query_async::<u64>(&mut connection)
|
||||
.await
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_cooldown_key_ids(
|
||||
runner: &RedisKvRunner,
|
||||
provider_id: &str,
|
||||
) -> Vec<String> {
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
|
||||
return Vec::new();
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
redis::cmd("SMEMBERS")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id))
|
||||
.query_async::<Vec<String>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_pool_status_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider = state
|
||||
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut providers| providers.drain(..).next())?;
|
||||
let Some(pool_config) = admin_provider_pool_config(&provider) else {
|
||||
return Some(json!({
|
||||
"provider_id": provider.id,
|
||||
"provider_name": provider.name,
|
||||
"pool_enabled": false,
|
||||
"total_keys": 0,
|
||||
"total_sticky_sessions": 0,
|
||||
"keys": [],
|
||||
}));
|
||||
};
|
||||
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
|
||||
let runtime = match state.redis_kv_runner() {
|
||||
Some(runner) => {
|
||||
read_admin_provider_pool_runtime_state(&runner, &provider.id, &key_ids, pool_config)
|
||||
.await
|
||||
}
|
||||
None => AdminProviderPoolRuntimeState::default(),
|
||||
};
|
||||
let key_payloads = keys
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let cooldown_reason = runtime.cooldown_reason_by_key.get(&key.id).cloned();
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"is_active": key.is_active,
|
||||
"cooldown_reason": cooldown_reason,
|
||||
"cooldown_ttl_seconds": cooldown_reason
|
||||
.as_ref()
|
||||
.and_then(|_| runtime.cooldown_ttl_by_key.get(&key.id).copied()),
|
||||
"cost_window_usage": runtime.cost_window_usage_by_key.get(&key.id).copied().unwrap_or(0),
|
||||
"cost_limit": pool_config.cost_limit_per_key_tokens,
|
||||
"sticky_sessions": runtime.sticky_sessions_by_key.get(&key.id).copied().unwrap_or(0),
|
||||
"lru_score": runtime.lru_score_by_key.get(&key.id).copied(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(json!({
|
||||
"provider_id": provider.id,
|
||||
"provider_name": provider.name,
|
||||
"pool_enabled": true,
|
||||
"total_keys": key_payloads.len(),
|
||||
"total_sticky_sessions": runtime.total_sticky_sessions,
|
||||
"keys": key_payloads,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_admin_provider_pool_cooldown(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) {
|
||||
let Some(runner) = state.redis_kv_runner() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis to clear cooldown for key {key_id}");
|
||||
return;
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
let _: Result<(), _> = redis::pipe()
|
||||
.cmd("DEL")
|
||||
.arg(pool_cooldown_key(&keyspace, provider_id, key_id))
|
||||
.ignore()
|
||||
.cmd("SREM")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id))
|
||||
.arg(key_id)
|
||||
.ignore()
|
||||
.query_async(&mut connection)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn reset_admin_provider_pool_cost(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) {
|
||||
let Some(runner) = state.redis_kv_runner() else {
|
||||
return;
|
||||
};
|
||||
let _ = runner.del(&format!("ap:{provider_id}:cost:{key_id}")).await;
|
||||
}
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod runtime;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use crate::handlers::admin::provider::shared::support::AdminProviderPoolConfig;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
fn json_u64(value: &Value) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_i64().and_then(|raw| u64::try_from(raw).ok()))
|
||||
}
|
||||
|
||||
fn admin_provider_pool_lru_enabled(raw_pool_advanced: &Map<String, Value>) -> bool {
|
||||
if let Some(explicit) = raw_pool_advanced
|
||||
.get("lru_enabled")
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
return explicit;
|
||||
}
|
||||
|
||||
let Some(presets) = raw_pool_advanced
|
||||
.get("scheduling_presets")
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(first) = presets.first() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if first.is_string() {
|
||||
return raw_pool_advanced
|
||||
.get("lru_enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
|
||||
presets.iter().filter_map(Value::as_object).any(|item| {
|
||||
item.get("preset")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|preset| preset.eq_ignore_ascii_case("lru"))
|
||||
&& item.get("enabled").and_then(Value::as_bool).unwrap_or(true)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn admin_provider_pool_config(
|
||||
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
|
||||
) -> Option<AdminProviderPoolConfig> {
|
||||
let raw_pool_advanced = provider
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|config| config.get("pool_advanced"))?;
|
||||
|
||||
let Some(pool_advanced) = raw_pool_advanced.as_object() else {
|
||||
return Some(AdminProviderPoolConfig {
|
||||
lru_enabled: false,
|
||||
cost_window_seconds: 18_000,
|
||||
cost_limit_per_key_tokens: None,
|
||||
});
|
||||
};
|
||||
|
||||
Some(AdminProviderPoolConfig {
|
||||
lru_enabled: admin_provider_pool_lru_enabled(pool_advanced),
|
||||
cost_window_seconds: pool_advanced
|
||||
.get("cost_window_seconds")
|
||||
.and_then(json_u64)
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(18_000),
|
||||
cost_limit_per_key_tokens: pool_advanced
|
||||
.get("cost_limit_per_key_tokens")
|
||||
.and_then(json_u64),
|
||||
})
|
||||
}
|
||||
409
apps/aether-gateway/src/handlers/admin/provider/pool/runtime.rs
Normal file
409
apps/aether-gateway/src/handlers/admin/provider/pool/runtime.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
use super::config::admin_provider_pool_config;
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
AdminProviderPoolConfig, AdminProviderPoolRuntimeState, ADMIN_PROVIDER_POOL_SCAN_BATCH,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data::redis::{RedisKeyspace, RedisKvRunner};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::warn;
|
||||
|
||||
fn pool_sticky_pattern(keyspace: &RedisKeyspace, provider_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:sticky:*"))
|
||||
}
|
||||
|
||||
fn pool_lru_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:lru"))
|
||||
}
|
||||
|
||||
fn pool_cooldown_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:cooldown:{key_id}"))
|
||||
}
|
||||
|
||||
fn pool_cooldown_index_key(keyspace: &RedisKeyspace, provider_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:cooldown_idx"))
|
||||
}
|
||||
|
||||
fn pool_cost_key(keyspace: &RedisKeyspace, provider_id: &str, key_id: &str) -> String {
|
||||
keyspace.key(&format!("ap:{provider_id}:cost:{key_id}"))
|
||||
}
|
||||
|
||||
fn parse_pool_cost_member(member: &str) -> u64 {
|
||||
member
|
||||
.rsplit_once(':')
|
||||
.and_then(|(_, suffix)| suffix.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn scan_redis_keys(
|
||||
connection: &mut redis::aio::MultiplexedConnection,
|
||||
pattern: &str,
|
||||
) -> Result<Vec<String>, GatewayError> {
|
||||
let mut cursor = 0u64;
|
||||
let mut keys = Vec::new();
|
||||
loop {
|
||||
let (next_cursor, batch): (u64, Vec<String>) = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(pattern)
|
||||
.arg("COUNT")
|
||||
.arg(ADMIN_PROVIDER_POOL_SCAN_BATCH)
|
||||
.query_async(connection)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
keys.extend(batch);
|
||||
if next_cursor == 0 {
|
||||
break;
|
||||
}
|
||||
cursor = next_cursor;
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn pool_cooldown_keys(
|
||||
keyspace: &RedisKeyspace,
|
||||
provider_id: &str,
|
||||
key_ids: &[String],
|
||||
) -> Vec<String> {
|
||||
key_ids
|
||||
.iter()
|
||||
.map(|key_id| pool_cooldown_key(keyspace, provider_id, key_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pool_cost_keys(keyspace: &RedisKeyspace, provider_id: &str, key_ids: &[String]) -> Vec<String> {
|
||||
key_ids
|
||||
.iter()
|
||||
.map(|key_id| pool_cost_key(keyspace, provider_id, key_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_cooldown_counts(
|
||||
runner: &RedisKvRunner,
|
||||
provider_ids: &[String],
|
||||
) -> BTreeMap<String, usize> {
|
||||
if provider_ids.is_empty() {
|
||||
return BTreeMap::new();
|
||||
}
|
||||
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for cooldown counts");
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
let mut pipeline = redis::pipe();
|
||||
for provider_id in provider_ids {
|
||||
pipeline
|
||||
.cmd("SCARD")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id));
|
||||
}
|
||||
|
||||
match pipeline.query_async::<Vec<u64>>(&mut connection).await {
|
||||
Ok(counts) => provider_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.zip(counts.into_iter())
|
||||
.map(|(provider_id, count)| (provider_id, count as usize))
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cooldown counts: {:?}",
|
||||
err
|
||||
);
|
||||
BTreeMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_runtime_state(
|
||||
runner: &RedisKvRunner,
|
||||
provider_id: &str,
|
||||
key_ids: &[String],
|
||||
pool_config: AdminProviderPoolConfig,
|
||||
) -> AdminProviderPoolRuntimeState {
|
||||
let mut runtime = AdminProviderPoolRuntimeState::default();
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
|
||||
return runtime;
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
let cooldown_keys = pool_cooldown_keys(&keyspace, provider_id, key_ids);
|
||||
let cost_keys = pool_cost_keys(&keyspace, provider_id, key_ids);
|
||||
|
||||
let sticky_keys = match scan_redis_keys(
|
||||
&mut connection,
|
||||
&pool_sticky_pattern(&keyspace, provider_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(keys) => keys,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to scan sticky keys for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
runtime.total_sticky_sessions = sticky_keys.len();
|
||||
if !sticky_keys.is_empty() {
|
||||
for chunk in sticky_keys.chunks(ADMIN_PROVIDER_POOL_SCAN_BATCH as usize) {
|
||||
let values = redis::cmd("MGET")
|
||||
.arg(chunk)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await;
|
||||
let Ok(values) = values else {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to read sticky bindings for provider {provider_id}"
|
||||
);
|
||||
break;
|
||||
};
|
||||
for bound_key_id in values.into_iter().flatten() {
|
||||
*runtime
|
||||
.sticky_sessions_by_key
|
||||
.entry(bound_key_id)
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cooldown_keys.is_empty() {
|
||||
let cooldown_reasons = redis::cmd("MGET")
|
||||
.arg(&cooldown_keys)
|
||||
.query_async::<Vec<Option<String>>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cooldown reasons for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
vec![None; cooldown_keys.len()]
|
||||
});
|
||||
let mut ttl_pipeline = redis::pipe();
|
||||
for cooldown_key in &cooldown_keys {
|
||||
ttl_pipeline.cmd("TTL").arg(cooldown_key);
|
||||
}
|
||||
let cooldown_ttls = ttl_pipeline
|
||||
.query_async::<Vec<i64>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cooldown ttl for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
vec![-1; cooldown_keys.len()]
|
||||
});
|
||||
|
||||
for (((key_id, _cooldown_key), reason), ttl) in key_ids
|
||||
.iter()
|
||||
.zip(cooldown_keys.iter())
|
||||
.zip(cooldown_reasons.into_iter())
|
||||
.zip(cooldown_ttls.into_iter())
|
||||
{
|
||||
if let Some(reason) = reason {
|
||||
runtime
|
||||
.cooldown_reason_by_key
|
||||
.insert(key_id.clone(), reason);
|
||||
if let Ok(ttl_seconds) = u64::try_from(ttl) {
|
||||
if ttl_seconds > 0 {
|
||||
runtime
|
||||
.cooldown_ttl_by_key
|
||||
.insert(key_id.clone(), ttl_seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cost_keys.is_empty() {
|
||||
let window_start = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.saturating_sub(pool_config.cost_window_seconds);
|
||||
let mut cost_pipeline = redis::pipe();
|
||||
for cost_key in &cost_keys {
|
||||
cost_pipeline
|
||||
.cmd("ZRANGEBYSCORE")
|
||||
.arg(cost_key)
|
||||
.arg(window_start)
|
||||
.arg("+inf");
|
||||
}
|
||||
let members_by_key = cost_pipeline
|
||||
.query_async::<Vec<Vec<String>>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"gateway admin provider pool: failed to batch read cost windows for provider {provider_id}: {:?}",
|
||||
err
|
||||
);
|
||||
vec![Vec::new(); cost_keys.len()]
|
||||
});
|
||||
for (key_id, members) in key_ids.iter().zip(members_by_key.into_iter()) {
|
||||
let total = members
|
||||
.iter()
|
||||
.map(|member| parse_pool_cost_member(member))
|
||||
.sum::<u64>();
|
||||
runtime
|
||||
.cost_window_usage_by_key
|
||||
.insert(key_id.clone(), total);
|
||||
}
|
||||
}
|
||||
|
||||
if pool_config.lru_enabled && !key_ids.is_empty() {
|
||||
let mut command = redis::cmd("ZMSCORE");
|
||||
command.arg(pool_lru_key(&keyspace, provider_id));
|
||||
for key_id in key_ids {
|
||||
command.arg(key_id);
|
||||
}
|
||||
if let Ok(scores) = command
|
||||
.query_async::<Vec<Option<f64>>>(&mut connection)
|
||||
.await
|
||||
{
|
||||
for (key_id, score) in key_ids.iter().zip(scores.into_iter()) {
|
||||
if let Some(score) = score {
|
||||
runtime.lru_score_by_key.insert(key_id.clone(), score);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_cooldown_count(
|
||||
runner: &RedisKvRunner,
|
||||
provider_id: &str,
|
||||
) -> usize {
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
|
||||
return 0;
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
redis::cmd("SCARD")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id))
|
||||
.query_async::<u64>(&mut connection)
|
||||
.await
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_provider_pool_cooldown_key_ids(
|
||||
runner: &RedisKvRunner,
|
||||
provider_id: &str,
|
||||
) -> Vec<String> {
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis for provider {provider_id}");
|
||||
return Vec::new();
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
redis::cmd("SMEMBERS")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id))
|
||||
.query_async::<Vec<String>>(&mut connection)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_pool_status_payload(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider = state
|
||||
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut providers| providers.drain(..).next())?;
|
||||
let Some(pool_config) = admin_provider_pool_config(&provider) else {
|
||||
return Some(json!({
|
||||
"provider_id": provider.id,
|
||||
"provider_name": provider.name,
|
||||
"pool_enabled": false,
|
||||
"total_keys": 0,
|
||||
"total_sticky_sessions": 0,
|
||||
"keys": [],
|
||||
}));
|
||||
};
|
||||
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
|
||||
let runtime = match state.redis_kv_runner() {
|
||||
Some(runner) => {
|
||||
read_admin_provider_pool_runtime_state(&runner, &provider.id, &key_ids, pool_config)
|
||||
.await
|
||||
}
|
||||
None => AdminProviderPoolRuntimeState::default(),
|
||||
};
|
||||
let key_payloads = keys
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let cooldown_reason = runtime.cooldown_reason_by_key.get(&key.id).cloned();
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"is_active": key.is_active,
|
||||
"cooldown_reason": cooldown_reason,
|
||||
"cooldown_ttl_seconds": cooldown_reason
|
||||
.as_ref()
|
||||
.and_then(|_| runtime.cooldown_ttl_by_key.get(&key.id).copied()),
|
||||
"cost_window_usage": runtime.cost_window_usage_by_key.get(&key.id).copied().unwrap_or(0),
|
||||
"cost_limit": pool_config.cost_limit_per_key_tokens,
|
||||
"sticky_sessions": runtime.sticky_sessions_by_key.get(&key.id).copied().unwrap_or(0),
|
||||
"lru_score": runtime.lru_score_by_key.get(&key.id).copied(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(json!({
|
||||
"provider_id": provider.id,
|
||||
"provider_name": provider.name,
|
||||
"pool_enabled": true,
|
||||
"total_keys": key_payloads.len(),
|
||||
"total_sticky_sessions": runtime.total_sticky_sessions,
|
||||
"keys": key_payloads,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_admin_provider_pool_cooldown(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) {
|
||||
let Some(runner) = state.redis_kv_runner() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut connection) = runner.client().get_multiplexed_async_connection().await else {
|
||||
warn!("gateway admin provider pool: failed to connect redis to clear cooldown for key {key_id}");
|
||||
return;
|
||||
};
|
||||
let keyspace = runner.keyspace().clone();
|
||||
let _: Result<(), _> = redis::pipe()
|
||||
.cmd("DEL")
|
||||
.arg(pool_cooldown_key(&keyspace, provider_id, key_id))
|
||||
.ignore()
|
||||
.cmd("SREM")
|
||||
.arg(pool_cooldown_index_key(&keyspace, provider_id))
|
||||
.arg(key_id)
|
||||
.ignore()
|
||||
.query_async(&mut connection)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn reset_admin_provider_pool_cost(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) {
|
||||
let Some(runner) = state.redis_kv_runner() else {
|
||||
return;
|
||||
};
|
||||
let _ = runner.del(&format!("ap:{provider_id}:cost:{key_id}")).await;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{
|
||||
use super::support::{
|
||||
admin_pool_provider_id_from_path, build_admin_pool_error_response,
|
||||
ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||
@@ -6,7 +6,7 @@ use super::{
|
||||
};
|
||||
use super::{payloads as pool_payloads, selection as pool_selection};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::pool::{
|
||||
use crate::handlers::admin::provider::pool::runtime::{
|
||||
clear_admin_provider_pool_cooldown, reset_admin_provider_pool_cost,
|
||||
};
|
||||
use crate::handlers::admin::shared::{
|
||||
|
||||
@@ -1,137 +1,17 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
const ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL: &str =
|
||||
"Admin pool overview requires provider catalog reader";
|
||||
const ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL: &str =
|
||||
"Admin pool cleanup requires provider catalog writer";
|
||||
const ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE: &str = "未发现可清理的异常账号";
|
||||
mod support;
|
||||
|
||||
mod batch_routes;
|
||||
mod payloads;
|
||||
mod read_routes;
|
||||
mod selection;
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
struct AdminPoolResolveSelectionRequest {
|
||||
#[serde(default)]
|
||||
search: String,
|
||||
#[serde(default)]
|
||||
quick_selectors: Vec<String>,
|
||||
}
|
||||
|
||||
fn build_admin_pool_error_response(
|
||||
status: http::StatusCode,
|
||||
detail: impl Into<String>,
|
||||
) -> Response<Body> {
|
||||
(status, Json(json!({ "detail": detail.into() }))).into_response()
|
||||
}
|
||||
|
||||
fn parse_admin_pool_page(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "page") {
|
||||
None => Ok(1),
|
||||
Some(value) => {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "page must be an integer between 1 and 10000".to_string())?;
|
||||
if (1..=10_000).contains(&parsed) {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err("page must be an integer between 1 and 10000".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_admin_pool_page_size(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "page_size") {
|
||||
None => Ok(50),
|
||||
Some(value) => {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "page_size must be an integer between 1 and 200".to_string())?;
|
||||
if (1..=200).contains(&parsed) {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err("page_size must be an integer between 1 and 200".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_admin_pool_search(query: Option<&str>) -> Option<String> {
|
||||
query_param_value(query, "search")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<String, String> {
|
||||
let value = query_param_value(query, "status")
|
||||
.unwrap_or_else(|| "all".to_string())
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"all" | "active" | "inactive" | "cooldown" => Ok(value),
|
||||
_ => Err("status must be one of: all, active, cooldown, inactive".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_provider_id_from_path(request_path: &str) -> Option<String> {
|
||||
let raw = request_path.strip_prefix("/api/admin/pool/")?;
|
||||
let mut segments = raw.split('/');
|
||||
let provider_id = segments.next()?.trim();
|
||||
let keys_segment = segments.next()?.trim();
|
||||
if provider_id.is_empty() || keys_segment != "keys" {
|
||||
None
|
||||
} else {
|
||||
Some(provider_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_admin_pool_route(request_context: &GatewayPublicRequestContext) -> bool {
|
||||
let normalized_path = request_context.request_path.trim_end_matches('/');
|
||||
let path = if normalized_path.is_empty() {
|
||||
request_context.request_path.as_str()
|
||||
} else {
|
||||
normalized_path
|
||||
};
|
||||
|
||||
(request_context.request_method == http::Method::GET && path == "/api/admin/pool/overview")
|
||||
|| (request_context.request_method == http::Method::GET
|
||||
&& path == "/api/admin/pool/scheduling-presets")
|
||||
|| (request_context.request_method == http::Method::GET
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys")
|
||||
&& path.matches('/').count() == 5)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/batch-import")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/batch-action")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/resolve-selection")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.request_method == http::Method::GET
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.contains("/keys/batch-delete-task/")
|
||||
&& path.matches('/').count() == 7)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/cleanup-banned")
|
||||
&& path.matches('/').count() == 6)
|
||||
}
|
||||
use self::support::{build_admin_pool_error_response, is_admin_pool_route};
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_pool_response(
|
||||
state: &AppState,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
AdminProviderPoolConfig, AdminProviderPoolRuntimeState,
|
||||
};
|
||||
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
use super::{
|
||||
use super::support::{
|
||||
admin_pool_provider_id_from_path, build_admin_pool_error_response, parse_admin_pool_page,
|
||||
parse_admin_pool_page_size, parse_admin_pool_search, parse_admin_pool_status_filter,
|
||||
AdminPoolResolveSelectionRequest, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use super::{payloads as pool_payloads, selection as pool_selection};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::pool::{
|
||||
admin_provider_pool_config, read_admin_provider_pool_cooldown_counts,
|
||||
read_admin_provider_pool_cooldown_key_ids, read_admin_provider_pool_runtime_state,
|
||||
use crate::handlers::admin::provider::pool::config::admin_provider_pool_config;
|
||||
use crate::handlers::admin::provider::pool::runtime::{
|
||||
read_admin_provider_pool_cooldown_counts, read_admin_provider_pool_cooldown_key_ids,
|
||||
read_admin_provider_pool_runtime_state,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::AdminProviderPoolRuntimeState;
|
||||
use crate::handlers::admin::provider::shared::support::AdminProviderPoolRuntimeState;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyListQuery;
|
||||
use axum::{
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) const ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL: &str =
|
||||
"Admin pool overview requires provider catalog reader";
|
||||
pub(super) const ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL: &str =
|
||||
"Admin pool cleanup requires provider catalog writer";
|
||||
pub(super) const ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE: &str = "未发现可清理的异常账号";
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(super) struct AdminPoolResolveSelectionRequest {
|
||||
#[serde(default)]
|
||||
pub(crate) search: String,
|
||||
#[serde(default)]
|
||||
pub(crate) quick_selectors: Vec<String>,
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_pool_error_response(
|
||||
status: http::StatusCode,
|
||||
detail: impl Into<String>,
|
||||
) -> Response<Body> {
|
||||
(status, Json(json!({ "detail": detail.into() }))).into_response()
|
||||
}
|
||||
|
||||
pub(super) fn parse_admin_pool_page(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "page") {
|
||||
None => Ok(1),
|
||||
Some(value) => {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "page must be an integer between 1 and 10000".to_string())?;
|
||||
if (1..=10_000).contains(&parsed) {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err("page must be an integer between 1 and 10000".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_admin_pool_page_size(query: Option<&str>) -> Result<usize, String> {
|
||||
match query_param_value(query, "page_size") {
|
||||
None => Ok(50),
|
||||
Some(value) => {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "page_size must be an integer between 1 and 200".to_string())?;
|
||||
if (1..=200).contains(&parsed) {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err("page_size must be an integer between 1 and 200".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_admin_pool_search(query: Option<&str>) -> Option<String> {
|
||||
query_param_value(query, "search")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<String, String> {
|
||||
let value = query_param_value(query, "status")
|
||||
.unwrap_or_else(|| "all".to_string())
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"all" | "active" | "inactive" | "cooldown" => Ok(value),
|
||||
_ => Err("status must be one of: all, active, cooldown, inactive".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_pool_provider_id_from_path(request_path: &str) -> Option<String> {
|
||||
let raw = request_path.strip_prefix("/api/admin/pool/")?;
|
||||
let mut segments = raw.split('/');
|
||||
let provider_id = segments.next()?.trim();
|
||||
let keys_segment = segments.next()?.trim();
|
||||
if provider_id.is_empty() || keys_segment != "keys" {
|
||||
None
|
||||
} else {
|
||||
Some(provider_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_admin_pool_route(request_context: &GatewayPublicRequestContext) -> bool {
|
||||
let normalized_path = request_context.request_path.trim_end_matches('/');
|
||||
let path = if normalized_path.is_empty() {
|
||||
request_context.request_path.as_str()
|
||||
} else {
|
||||
normalized_path
|
||||
};
|
||||
|
||||
(request_context.request_method == http::Method::GET && path == "/api/admin/pool/overview")
|
||||
|| (request_context.request_method == http::Method::GET
|
||||
&& path == "/api/admin/pool/scheduling-presets")
|
||||
|| (request_context.request_method == http::Method::GET
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys")
|
||||
&& path.matches('/').count() == 5)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/batch-import")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/batch-action")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/resolve-selection")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.request_method == http::Method::GET
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.contains("/keys/batch-delete-task/")
|
||||
&& path.matches('/').count() == 7)
|
||||
|| (request_context.request_method == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/cleanup-banned")
|
||||
&& path.matches('/').count() == 6)
|
||||
}
|
||||
@@ -4,8 +4,9 @@ use axum::body::{Body, Bytes};
|
||||
use axum::http::Response;
|
||||
|
||||
mod models;
|
||||
mod payload;
|
||||
mod response;
|
||||
mod routes;
|
||||
mod shared;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_provider_query_response(
|
||||
state: &AppState,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::shared::{
|
||||
use super::payload::{provider_query_extract_api_key_id, provider_query_extract_provider_id};
|
||||
use super::response::{
|
||||
build_admin_provider_query_bad_request_response, build_admin_provider_query_not_found_response,
|
||||
provider_query_extract_api_key_id, provider_query_extract_provider_id,
|
||||
ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL, ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL,
|
||||
ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL, ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL,
|
||||
ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL,
|
||||
|
||||
@@ -1,46 +1,10 @@
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL: &str = "Invalid JSON request body";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL: &str = "provider_id is required";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_MODEL_REQUIRED_DETAIL: &str = "model is required";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL: &str =
|
||||
"failover_models should not be empty";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL: &str = "Provider not found";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL: &str = "API Key not found";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL: &str =
|
||||
"No active API Key found for this provider";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL: &str =
|
||||
"No models available from local provider catalog";
|
||||
|
||||
pub(super) fn build_admin_provider_query_bad_request_response(
|
||||
detail: &'static str,
|
||||
) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_provider_query_not_found_response(
|
||||
detail: &'static str,
|
||||
) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) fn parse_admin_provider_query_body(
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<serde_json::Value, Response<Body>> {
|
||||
) -> Result<serde_json::Value, Response<axum::body::Body>> {
|
||||
let Some(raw_body) = request_body else {
|
||||
return Ok(json!({}));
|
||||
};
|
||||
@@ -48,7 +12,9 @@ pub(super) fn parse_admin_provider_query_body(
|
||||
return Ok(json!({}));
|
||||
}
|
||||
serde_json::from_slice::<serde_json::Value>(raw_body).map_err(|_| {
|
||||
build_admin_provider_query_bad_request_response(ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL)
|
||||
super::response::build_admin_provider_query_bad_request_response(
|
||||
super::response::ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_INVALID_JSON_DETAIL: &str = "Invalid JSON request body";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL: &str = "provider_id is required";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_MODEL_REQUIRED_DETAIL: &str = "model is required";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL: &str =
|
||||
"failover_models should not be empty";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL: &str = "Provider not found";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL: &str = "API Key not found";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL: &str =
|
||||
"No active API Key found for this provider";
|
||||
pub(super) const ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL: &str =
|
||||
"No models available from local provider catalog";
|
||||
|
||||
pub(super) fn build_admin_provider_query_bad_request_response(
|
||||
detail: &'static str,
|
||||
) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_provider_query_not_found_response(
|
||||
detail: &'static str,
|
||||
) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -3,11 +3,14 @@ use super::models::{
|
||||
build_admin_provider_query_test_model_failover_response,
|
||||
build_admin_provider_query_test_model_response,
|
||||
};
|
||||
use super::shared::{
|
||||
build_admin_provider_query_bad_request_response, parse_admin_provider_query_body,
|
||||
provider_query_extract_failover_models, provider_query_extract_model,
|
||||
provider_query_extract_provider_id, provider_query_extract_request_id,
|
||||
provider_query_payload_keys, ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL,
|
||||
use super::payload::{
|
||||
parse_admin_provider_query_body, provider_query_extract_failover_models,
|
||||
provider_query_extract_model, provider_query_extract_provider_id,
|
||||
provider_query_extract_request_id, provider_query_payload_keys,
|
||||
};
|
||||
use super::response::{
|
||||
build_admin_provider_query_bad_request_response,
|
||||
ADMIN_PROVIDER_QUERY_FAILOVER_MODELS_REQUIRED_DETAIL,
|
||||
ADMIN_PROVIDER_QUERY_MODEL_REQUIRED_DETAIL, ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
mod paths;
|
||||
mod payloads;
|
||||
mod support;
|
||||
|
||||
pub(crate) use self::paths::*;
|
||||
pub(crate) use self::payloads::*;
|
||||
pub(crate) use self::support::*;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod payloads;
|
||||
pub(crate) mod support;
|
||||
|
||||
@@ -48,3 +48,24 @@ pub(crate) fn put_admin_provider_delete_task(
|
||||
) {
|
||||
state.put_provider_delete_task(task.clone());
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_billing_type(value: &str) -> Result<String, String> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"monthly_quota" | "pay_as_you_go" | "free_tier" => Ok(normalized),
|
||||
_ => Err("billing_type 仅支持 monthly_quota / pay_as_you_go / free_tier".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_optional_rfc3339_unix_secs(
|
||||
value: &str,
|
||||
field_name: &str,
|
||||
) -> Result<u64, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{field_name} 不能为空"));
|
||||
}
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(trimmed)
|
||||
.map_err(|_| format!("{field_name} 必须是合法的 RFC3339 时间"))?;
|
||||
u64::try_from(parsed.timestamp()).map_err(|_| format!("{field_name} 超出有效时间范围"))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::super::write::{normalize_provider_billing_type, parse_optional_rfc3339_unix_secs};
|
||||
use super::shared::admin_provider_strategy_provider_not_found_response;
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
|
||||
};
|
||||
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
@@ -257,3 +258,11 @@ pub(super) async fn build_provider_strategy_reset_quota_response(
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn admin_provider_strategy_provider_not_found_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider not found" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ use axum::body::{Body, Bytes};
|
||||
use axum::http::Response;
|
||||
|
||||
mod builders;
|
||||
mod responses;
|
||||
mod routes;
|
||||
mod shared;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_provider_strategy_response(
|
||||
state: &AppState,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
use crate::handlers::admin::shared::build_proxy_error_response;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use axum::{body::Body, http, response::Response};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) const ADMIN_PROVIDER_STRATEGY_DATA_UNAVAILABLE_DETAIL: &str =
|
||||
@@ -20,19 +15,3 @@ pub(super) fn admin_provider_strategy_data_unavailable_response(detail: &str) ->
|
||||
Some(json!({ "error": detail })),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn admin_provider_strategy_provider_not_found_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider not found" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) fn admin_provider_strategy_dispatcher_not_found_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider strategy route not found" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -3,15 +3,13 @@ use super::builders::{
|
||||
build_provider_strategy_stats_response, build_provider_strategy_update_billing_response,
|
||||
AdminProviderStrategyBillingRequest,
|
||||
};
|
||||
use super::shared::{
|
||||
use super::responses::{
|
||||
admin_provider_strategy_data_unavailable_response,
|
||||
admin_provider_strategy_dispatcher_not_found_response,
|
||||
admin_provider_strategy_provider_not_found_response,
|
||||
ADMIN_PROVIDER_STRATEGY_DATA_UNAVAILABLE_DETAIL,
|
||||
ADMIN_PROVIDER_STRATEGY_STATS_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::paths::{
|
||||
admin_provider_id_for_provider_strategy_billing, admin_provider_id_for_provider_strategy_quota,
|
||||
admin_provider_id_for_provider_strategy_stats, is_admin_provider_strategy_strategies_root,
|
||||
};
|
||||
@@ -134,3 +132,19 @@ pub(super) async fn maybe_build_local_admin_provider_strategy_response(
|
||||
|
||||
Ok(Some(admin_provider_strategy_dispatcher_not_found_response()))
|
||||
}
|
||||
|
||||
fn admin_provider_strategy_provider_not_found_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider not found" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn admin_provider_strategy_dispatcher_not_found_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Provider strategy route not found" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use super::{
|
||||
normalize_auth_type, normalize_json_object, normalize_string_list, validate_vertex_api_formats,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use super::normalize::{normalize_auth_type, validate_vertex_api_formats};
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminProviderKeyCreateRequest, AdminProviderKeyUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::shared::{
|
||||
build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks,
|
||||
encrypt_catalog_secret_with_fallbacks, json_string_list, parse_catalog_auth_config_json,
|
||||
encrypt_catalog_secret_with_fallbacks, json_string_list, normalize_json_object,
|
||||
normalize_string_list, parse_catalog_auth_config_json,
|
||||
};
|
||||
use crate::AppState;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
|
||||
@@ -1,85 +1,4 @@
|
||||
use crate::handlers::admin::shared::{normalize_json_object, normalize_string_list};
|
||||
|
||||
pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, String> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"custom" | "claude_code" | "kiro" | "codex" | "gemini_cli" | "antigravity"
|
||||
| "vertex_ai" => Ok(normalized),
|
||||
_ => Err(
|
||||
"provider_type 仅支持 custom / claude_code / kiro / codex / gemini_cli / antigravity / vertex_ai"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_billing_type(value: &str) -> Result<String, String> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"monthly_quota" | "pay_as_you_go" | "free_tier" => Ok(normalized),
|
||||
_ => Err("billing_type 仅支持 monthly_quota / pay_as_you_go / free_tier".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_optional_rfc3339_unix_secs(
|
||||
value: &str,
|
||||
field_name: &str,
|
||||
) -> Result<u64, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{field_name} 不能为空"));
|
||||
}
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(trimmed)
|
||||
.map_err(|_| format!("{field_name} 必须是合法的 RFC3339 时间"))?;
|
||||
u64::try_from(parsed.timestamp()).map_err(|_| format!("{field_name} 超出有效时间范围"))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> {
|
||||
let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase();
|
||||
match auth_type.as_str() {
|
||||
"api_key" | "service_account" | "oauth" => Ok(auth_type),
|
||||
_ => Err("auth_type 仅支持 api_key / service_account / oauth".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_vertex_api_formats(
|
||||
provider_type: &str,
|
||||
auth_type: &str,
|
||||
api_formats: &[String],
|
||||
) -> Result<(), String> {
|
||||
if !provider_type.trim().eq_ignore_ascii_case("vertex_ai") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let allowed = match auth_type {
|
||||
"api_key" => &["gemini:chat"][..],
|
||||
"service_account" | "vertex_ai" => &["claude:chat", "gemini:chat"][..],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let invalid = api_formats
|
||||
.iter()
|
||||
.filter(|value| !allowed.contains(&value.as_str()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if invalid.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"Vertex {auth_type} 不支持以下 API 格式: {};允许: {}",
|
||||
invalid.join(", "),
|
||||
allowed.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
mod keys;
|
||||
mod provider;
|
||||
mod reveal;
|
||||
|
||||
pub(crate) use self::keys::{
|
||||
build_admin_create_provider_key_record, build_admin_provider_keys_payload,
|
||||
build_admin_update_provider_key_record,
|
||||
};
|
||||
pub(crate) use self::provider::{
|
||||
build_admin_create_provider_record, build_admin_fixed_provider_endpoint_record,
|
||||
build_admin_update_provider_record,
|
||||
};
|
||||
pub(crate) use self::reveal::{build_admin_export_key_payload, build_admin_reveal_key_payload};
|
||||
pub(crate) mod keys;
|
||||
pub(crate) mod normalize;
|
||||
pub(crate) mod provider;
|
||||
pub(crate) mod reveal;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, String> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"custom" | "claude_code" | "kiro" | "codex" | "gemini_cli" | "antigravity"
|
||||
| "vertex_ai" => Ok(normalized),
|
||||
_ => Err(
|
||||
"provider_type 仅支持 custom / claude_code / kiro / codex / gemini_cli / antigravity / vertex_ai"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> {
|
||||
let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase();
|
||||
match auth_type.as_str() {
|
||||
"api_key" | "service_account" | "oauth" => Ok(auth_type),
|
||||
_ => Err("auth_type 仅支持 api_key / service_account / oauth".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_vertex_api_formats(
|
||||
provider_type: &str,
|
||||
auth_type: &str,
|
||||
api_formats: &[String],
|
||||
) -> Result<(), String> {
|
||||
if !provider_type.trim().eq_ignore_ascii_case("vertex_ai") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let allowed = match auth_type {
|
||||
"api_key" => &["gemini:chat"][..],
|
||||
"service_account" | "vertex_ai" => &["claude:chat", "gemini:chat"][..],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let invalid = api_formats
|
||||
.iter()
|
||||
.filter(|value| !allowed.contains(&value.as_str()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if invalid.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"Vertex {auth_type} 不支持以下 API 格式: {};允许: {}",
|
||||
invalid.join(", "),
|
||||
allowed.join(", ")
|
||||
))
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::{
|
||||
normalize_json_object, normalize_provider_billing_type, normalize_provider_type_input,
|
||||
parse_optional_rfc3339_unix_secs,
|
||||
};
|
||||
use super::normalize::normalize_provider_type_input;
|
||||
use crate::api::ai::{admin_default_body_rules_for_signature, admin_endpoint_signature_parts};
|
||||
use crate::handlers::admin::provider::shared::{
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminProviderCreateRequest, AdminProviderUpdateRequest,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
normalize_provider_billing_type, parse_optional_rfc3339_unix_secs,
|
||||
};
|
||||
use crate::handlers::admin::shared::normalize_json_object;
|
||||
use crate::handlers::public::normalize_admin_base_url;
|
||||
use crate::provider_transport::provider_types::provider_type_enables_format_conversion_by_default;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub(super) use crate::handlers::admin::system::maybe_build_local_admin_management_tokens_response as maybe_build_local_admin_core_management_tokens_response;
|
||||
@@ -5,7 +5,6 @@ use axum::{
|
||||
http,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
const ADMIN_AWS_REGIONS: &[&str] = &[
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
@@ -37,10 +36,6 @@ const ADMIN_AWS_REGIONS: &[&str] = &[
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
];
|
||||
mod management_tokens_routes;
|
||||
mod model_routes;
|
||||
mod modules_routes;
|
||||
mod oauth_routes;
|
||||
mod system_routes;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_core_response(
|
||||
@@ -49,7 +44,7 @@ pub(crate) async fn maybe_build_local_admin_core_response(
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
if let Some(response) =
|
||||
management_tokens_routes::maybe_build_local_admin_core_management_tokens_response(
|
||||
crate::handlers::admin::system::maybe_build_local_admin_management_tokens_response(
|
||||
state,
|
||||
request_context,
|
||||
)
|
||||
@@ -57,7 +52,7 @@ pub(crate) async fn maybe_build_local_admin_core_response(
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
if let Some(response) = oauth_routes::maybe_build_local_admin_core_oauth_response(
|
||||
if let Some(response) = crate::handlers::admin::auth::maybe_build_local_admin_oauth_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
@@ -66,12 +61,13 @@ pub(crate) async fn maybe_build_local_admin_core_response(
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
if let Some(response) = modules_routes::maybe_build_local_admin_core_modules_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await?
|
||||
if let Some(response) =
|
||||
crate::handlers::admin::system::maybe_build_local_admin_modules_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
@@ -85,7 +81,11 @@ pub(crate) async fn maybe_build_local_admin_core_response(
|
||||
return Ok(Some(response));
|
||||
}
|
||||
if let Some(response) =
|
||||
model_routes::maybe_build_local_admin_core_model_response(state, request_context).await?
|
||||
crate::handlers::admin::model::maybe_build_local_admin_model_catalog_response(
|
||||
state,
|
||||
request_context,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub(super) use crate::handlers::admin::model::maybe_build_local_admin_model_catalog_response as maybe_build_local_admin_core_model_response;
|
||||
@@ -1 +0,0 @@
|
||||
pub(super) use crate::handlers::admin::system::maybe_build_local_admin_modules_response as maybe_build_local_admin_core_modules_response;
|
||||
@@ -1 +0,0 @@
|
||||
pub(super) use crate::handlers::admin::auth::maybe_build_local_admin_oauth_response as maybe_build_local_admin_core_oauth_response;
|
||||
@@ -2,18 +2,24 @@ use super::ADMIN_AWS_REGIONS;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use crate::handlers::admin::shared::build_proxy_error_response;
|
||||
use crate::handlers::admin::system::shared::{
|
||||
use crate::handlers::admin::system::shared::configs::{
|
||||
apply_admin_system_config_update, build_admin_system_config_detail_payload,
|
||||
build_admin_system_config_export_payload, build_admin_system_configs_payload,
|
||||
build_admin_system_users_export_payload, delete_admin_system_config,
|
||||
};
|
||||
use crate::handlers::admin::system::shared::email_templates::{
|
||||
apply_admin_email_template_update, build_admin_email_template_payload,
|
||||
build_admin_email_templates_payload, preview_admin_email_template, reset_admin_email_template,
|
||||
};
|
||||
use crate::handlers::admin::system::shared::paths::{
|
||||
admin_system_config_key_from_path, admin_system_email_template_preview_type_from_path,
|
||||
admin_system_email_template_reset_type_from_path, admin_system_email_template_type_from_path,
|
||||
apply_admin_email_template_update, apply_admin_system_config_update,
|
||||
apply_admin_system_settings_update, build_admin_api_formats_payload,
|
||||
build_admin_email_template_payload, build_admin_email_templates_payload,
|
||||
build_admin_system_check_update_payload, build_admin_system_config_detail_payload,
|
||||
build_admin_system_config_export_payload, build_admin_system_configs_payload,
|
||||
build_admin_system_settings_payload, build_admin_system_stats_payload,
|
||||
build_admin_system_users_export_payload, current_aether_version, delete_admin_system_config,
|
||||
is_admin_system_configs_root, is_admin_system_email_templates_root,
|
||||
preview_admin_email_template, reset_admin_email_template,
|
||||
};
|
||||
use crate::handlers::admin::system::shared::settings::{
|
||||
apply_admin_system_settings_update, build_admin_api_formats_payload,
|
||||
build_admin_system_check_update_payload, build_admin_system_settings_payload,
|
||||
build_admin_system_stats_payload, current_aether_version,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
|
||||
use crate::handlers::admin::system::shared::{
|
||||
use crate::handlers::admin::system::shared::paths::{
|
||||
admin_management_token_id_from_path, admin_management_token_status_id_from_path,
|
||||
is_admin_management_tokens_root,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ mod adaptive;
|
||||
mod core;
|
||||
mod management_tokens;
|
||||
mod modules;
|
||||
mod pool;
|
||||
mod proxy_nodes;
|
||||
pub(crate) mod shared;
|
||||
|
||||
@@ -10,5 +9,5 @@ pub(crate) use self::adaptive::maybe_build_local_admin_adaptive_response;
|
||||
pub(crate) use self::core::maybe_build_local_admin_core_response;
|
||||
pub(crate) use self::management_tokens::maybe_build_local_admin_management_tokens_response;
|
||||
pub(crate) use self::modules::maybe_build_local_admin_modules_response;
|
||||
pub(crate) use self::pool::maybe_build_local_admin_pool_response;
|
||||
pub(crate) use self::proxy_nodes::maybe_build_local_admin_proxy_nodes_response;
|
||||
pub(crate) use crate::handlers::admin::provider::pool_admin::maybe_build_local_admin_pool_response;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::system::shared::{
|
||||
use crate::handlers::admin::system::shared::modules::{
|
||||
admin_module_by_name, admin_module_name_from_enabled_path, admin_module_name_from_status_path,
|
||||
build_admin_module_runtime_state, build_admin_module_status_payload,
|
||||
build_admin_module_validation_result, build_admin_modules_status_payload,
|
||||
AdminSetModuleEnabledRequest,
|
||||
};
|
||||
use crate::handlers::admin::system::shared::paths::{
|
||||
is_admin_system_configs_root, is_admin_system_email_templates_root,
|
||||
};
|
||||
use crate::handlers::shared::module_available_from_env;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub(crate) use crate::handlers::admin::provider::pool_admin::maybe_build_local_admin_pool_response;
|
||||
@@ -1,14 +1,5 @@
|
||||
mod configs;
|
||||
mod email_templates;
|
||||
mod modules;
|
||||
mod paths;
|
||||
mod settings;
|
||||
|
||||
pub(crate) use self::configs::*;
|
||||
pub(crate) use self::email_templates::{
|
||||
apply_admin_email_template_update, build_admin_email_template_payload,
|
||||
build_admin_email_templates_payload, preview_admin_email_template, reset_admin_email_template,
|
||||
};
|
||||
pub(crate) use self::modules::*;
|
||||
pub(crate) use self::paths::*;
|
||||
pub(crate) use self::settings::*;
|
||||
pub(crate) mod configs;
|
||||
pub(crate) mod email_templates;
|
||||
pub(crate) mod modules;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod settings;
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::execution_runtime::{
|
||||
maybe_build_local_video_success_outcome, resolve_local_sync_error_background_report_kind,
|
||||
resolve_local_sync_success_background_report_kind,
|
||||
};
|
||||
use crate::handlers::admin::provider::oauth::build_internal_control_error_response;
|
||||
use crate::handlers::admin::provider::oauth::refresh::build_internal_control_error_response;
|
||||
use crate::handlers::shared::{
|
||||
unix_secs_to_rfc3339, InternalTunnelHeartbeatRequest, InternalTunnelNodeStatusRequest,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::admin::provider::oauth::build_internal_control_error_response;
|
||||
use super::admin::provider::oauth::refresh::build_internal_control_error_response;
|
||||
|
||||
mod gateway_helpers;
|
||||
use self::gateway_helpers::*;
|
||||
|
||||
@@ -16,7 +16,7 @@ use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::handlers::admin::provider::ops::admin_provider_ops_local_action_response;
|
||||
use crate::handlers::admin::provider::ops::providers::actions::admin_provider_ops_local_action_response;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
#[path = "runtime/audit_cleanup.rs"]
|
||||
|
||||
@@ -6,7 +6,7 @@ use aether_data_contracts::repository::provider_catalog::{
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::handlers::admin::provider::ops::admin_provider_ops_local_action_response;
|
||||
use crate::handlers::admin::provider::ops::providers::actions::admin_provider_ops_local_action_response;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::{system_config_bool, PROVIDER_CHECKIN_CONCURRENCY};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_admin_handlers_do_not_depend_on_admin_stats_module() {
|
||||
let handlers_mod = read_workspace_file("apps/aether-gateway/src/handlers/mod.rs");
|
||||
assert!(
|
||||
!handlers_mod.contains("pub(crate) use admin::{"),
|
||||
"handlers/mod.rs should stay as pure module wiring after shared usage stats facade extraction"
|
||||
);
|
||||
|
||||
for path in [
|
||||
"apps/aether-gateway/src/handlers/public/support/user_me.rs",
|
||||
"apps/aether-gateway/src/handlers/public/support/wallet/reads.rs",
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_store.rs",
|
||||
] {
|
||||
let file = read_workspace_file(path);
|
||||
assert!(
|
||||
!file.contains("handlers::admin::stats::"),
|
||||
"{path} should not depend directly on admin::stats"
|
||||
);
|
||||
}
|
||||
|
||||
let admin_mod = read_workspace_file("apps/aether-gateway/src/handlers/admin/mod.rs");
|
||||
assert!(
|
||||
!admin_mod.contains("pub(crate) mod facade;"),
|
||||
"handlers/admin/mod.rs should not keep admin facade after direct subdomain exposure"
|
||||
);
|
||||
|
||||
let shared_mod = read_workspace_file("apps/aether-gateway/src/handlers/shared/mod.rs");
|
||||
for pattern in [
|
||||
"admin_stats_bad_request_response",
|
||||
"list_usage_for_optional_range",
|
||||
"parse_bounded_u32",
|
||||
"round_to",
|
||||
"AdminStatsTimeRange",
|
||||
"AdminStatsUsageFilter",
|
||||
] {
|
||||
assert!(
|
||||
shared_mod.contains(pattern),
|
||||
"handlers/shared/mod.rs should expose shared usage stats helper {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let admin_observability_mod =
|
||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/mod.rs");
|
||||
for pattern in [
|
||||
"admin_stats_bad_request_response",
|
||||
"aggregate_usage_stats",
|
||||
"list_usage_for_optional_range",
|
||||
"parse_bounded_u32",
|
||||
"round_to",
|
||||
"AdminStatsTimeRange",
|
||||
"AdminStatsUsageFilter",
|
||||
"match_admin_monitoring_route",
|
||||
"AdminMonitoringRoute",
|
||||
"ADMIN_MONITORING_REDIS_REQUIRED_DETAIL",
|
||||
"test_support",
|
||||
] {
|
||||
assert!(
|
||||
!admin_observability_mod.contains(pattern),
|
||||
"handlers/admin/observability/mod.rs should not re-export {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let shared_usage_stats =
|
||||
read_workspace_file("apps/aether-gateway/src/handlers/shared/usage_stats.rs");
|
||||
assert!(
|
||||
shared_usage_stats.contains("crate::handlers::admin::observability::stats::{"),
|
||||
"handlers/shared/usage_stats.rs should depend on observability::stats directly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_monitoring_root_stays_thin() {
|
||||
let monitoring_mod = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"mod common;",
|
||||
"use self::activity::{",
|
||||
"use self::cache::{",
|
||||
"use self::resilience::{",
|
||||
"use self::trace::{",
|
||||
"pub(crate) use self::routes::{",
|
||||
"const ADMIN_MONITORING_",
|
||||
] {
|
||||
assert!(
|
||||
!monitoring_mod.contains(pattern),
|
||||
"handlers/admin/observability/monitoring/mod.rs should not act as a glue re-export layer for {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
monitoring_mod.contains("routes::maybe_build_local_admin_monitoring_response"),
|
||||
"handlers/admin/observability/monitoring/mod.rs should delegate through routes module"
|
||||
);
|
||||
assert!(
|
||||
monitoring_mod.contains("mod cache_config;"),
|
||||
"handlers/admin/observability/monitoring/mod.rs should register cache_config as a dedicated cache boundary"
|
||||
);
|
||||
assert!(
|
||||
monitoring_mod.contains("mod cache_mutations;"),
|
||||
"handlers/admin/observability/monitoring/mod.rs should register cache_mutations as a dedicated mutation boundary"
|
||||
);
|
||||
assert!(
|
||||
monitoring_mod.contains("mod responses;"),
|
||||
"handlers/admin/observability/monitoring/mod.rs should register responses as a dedicated response boundary"
|
||||
);
|
||||
assert!(
|
||||
!workspace_file_exists(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/common.rs",
|
||||
),
|
||||
"handlers/admin/observability/monitoring/common.rs should stay removed after boundary split"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_stats_root_stays_thin() {
|
||||
let stats_mod =
|
||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/stats/mod.rs");
|
||||
for pattern in [
|
||||
"use self::leaderboard::{",
|
||||
"use self::responses::{",
|
||||
"use self::timeseries::{",
|
||||
"enum AdminStatsComparisonType",
|
||||
"enum AdminStatsGranularity",
|
||||
"struct AdminStatsForecastPoint",
|
||||
"struct AdminStatsLeaderboardItem",
|
||||
"struct AdminStatsUserMetadata",
|
||||
"struct AdminStatsTimeSeriesBucket",
|
||||
"impl AdminStatsTimeRange {",
|
||||
"pub(crate) fn round_to(",
|
||||
] {
|
||||
assert!(
|
||||
!stats_mod.contains(pattern),
|
||||
"handlers/admin/observability/stats/mod.rs should not own stats helper implementation {pattern}"
|
||||
);
|
||||
}
|
||||
for pattern in [
|
||||
"mod helpers;",
|
||||
"pub(crate) use self::helpers::{round_to, AdminStatsTimeRange, AdminStatsUsageFilter};",
|
||||
"pub(crate) use self::range::{list_usage_for_optional_range, parse_bounded_u32};",
|
||||
"pub(crate) use self::responses::admin_stats_bad_request_response;",
|
||||
"pub(crate) use self::timeseries::aggregate_usage_stats;",
|
||||
] {
|
||||
assert!(
|
||||
stats_mod.contains(pattern),
|
||||
"handlers/admin/observability/stats/mod.rs should stay as a thin seam for {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let analytics_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"use super::helpers::{",
|
||||
"use super::range::{",
|
||||
"use super::responses::{",
|
||||
"use super::timeseries::{",
|
||||
] {
|
||||
assert!(
|
||||
analytics_routes.contains(pattern),
|
||||
"stats/analytics_routes.rs should depend on split stats boundaries via {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let cost_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"use super::helpers::{",
|
||||
"use super::range::{",
|
||||
"use super::responses::{",
|
||||
"use super::timeseries::{",
|
||||
] {
|
||||
assert!(
|
||||
cost_routes.contains(pattern),
|
||||
"stats/cost_routes.rs should depend on split stats boundaries via {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let leaderboard_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"use super::helpers::{",
|
||||
"use super::leaderboard::{",
|
||||
"use super::range::{",
|
||||
"use super::responses::{",
|
||||
] {
|
||||
assert!(
|
||||
leaderboard_routes.contains(pattern),
|
||||
"stats/leaderboard_routes.rs should depend on split stats boundaries via {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let provider_quota_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/stats/provider_quota_routes.rs",
|
||||
);
|
||||
assert!(
|
||||
provider_quota_routes
|
||||
.contains("use super::responses::admin_stats_provider_quota_usage_empty_response;"),
|
||||
"stats/provider_quota_routes.rs should depend on responses boundary directly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_monitoring_cache_mutations_are_split_from_reads() {
|
||||
let monitoring_cache = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"pub(super) async fn build_admin_monitoring_cache_users_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_cache_affinity_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_cache_flush_response(",
|
||||
"pub(super) async fn build_admin_monitoring_cache_provider_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_model_mapping_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_model_mapping_delete_model_response(",
|
||||
"pub(super) async fn build_admin_monitoring_model_mapping_delete_provider_response(",
|
||||
"pub(super) async fn build_admin_monitoring_redis_keys_delete_response(",
|
||||
] {
|
||||
assert!(
|
||||
!monitoring_cache.contains(pattern),
|
||||
"monitoring/cache.rs should stay focused on read/report handlers, not {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let monitoring_cache_mutations = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_mutations.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"pub(super) async fn build_admin_monitoring_cache_users_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_cache_affinity_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_cache_flush_response(",
|
||||
"pub(super) async fn build_admin_monitoring_cache_provider_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_model_mapping_delete_response(",
|
||||
"pub(super) async fn build_admin_monitoring_model_mapping_delete_model_response(",
|
||||
"pub(super) async fn build_admin_monitoring_model_mapping_delete_provider_response(",
|
||||
"pub(super) async fn build_admin_monitoring_redis_keys_delete_response(",
|
||||
] {
|
||||
assert!(
|
||||
monitoring_cache_mutations.contains(pattern),
|
||||
"monitoring/cache_mutations.rs should own {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let monitoring_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/routes.rs",
|
||||
);
|
||||
assert!(
|
||||
monitoring_routes.contains("use super::cache_mutations::{"),
|
||||
"monitoring/routes.rs should depend on cache_mutations directly for delete handlers"
|
||||
);
|
||||
assert!(
|
||||
monitoring_routes.contains("use super::cache_affinity_reads::{"),
|
||||
"monitoring/routes.rs should depend on cache_affinity_reads directly for affinity read handlers"
|
||||
);
|
||||
assert!(
|
||||
monitoring_routes.contains("use super::cache_model_mapping::{"),
|
||||
"monitoring/routes.rs should depend on cache_model_mapping directly for model-mapping read handlers"
|
||||
);
|
||||
|
||||
let monitoring_mod = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs",
|
||||
);
|
||||
for pattern in ["mod cache_affinity_reads;", "mod cache_model_mapping;"] {
|
||||
assert!(
|
||||
monitoring_mod.contains(pattern),
|
||||
"monitoring/mod.rs should register split read module {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let monitoring_affinity_reads = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_affinity_reads.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"pub(super) async fn build_admin_monitoring_cache_affinities_response(",
|
||||
"pub(super) async fn build_admin_monitoring_cache_affinity_response(",
|
||||
] {
|
||||
assert!(
|
||||
monitoring_affinity_reads.contains(pattern),
|
||||
"monitoring/cache_affinity_reads.rs should own {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let monitoring_model_mapping = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_model_mapping.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"pub(super) async fn build_admin_monitoring_model_mapping_stats_response(",
|
||||
"pub(super) async fn build_admin_monitoring_redis_cache_categories_response(",
|
||||
] {
|
||||
assert!(
|
||||
monitoring_model_mapping.contains(pattern),
|
||||
"monitoring/cache_model_mapping.rs should own {pattern}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_usage_root_stays_thin() {
|
||||
let usage_mod =
|
||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/usage/mod.rs");
|
||||
for pattern in ["pub(crate) use analytics::{", "pub(crate) use helpers::{"] {
|
||||
assert!(
|
||||
!usage_mod.contains(pattern),
|
||||
"handlers/admin/observability/usage/mod.rs should not re-export helper seam {pattern}"
|
||||
);
|
||||
}
|
||||
for pattern in [
|
||||
"mod analytics;",
|
||||
"mod analytics_routes;",
|
||||
"mod detail_routes;",
|
||||
"mod helpers;",
|
||||
"mod replay;",
|
||||
"mod summary_routes;",
|
||||
"detail_routes::maybe_build_local_admin_usage_detail_response",
|
||||
"summary_routes::maybe_build_local_admin_usage_summary_response",
|
||||
"analytics_routes::maybe_build_local_admin_usage_analytics_response",
|
||||
] {
|
||||
assert!(
|
||||
usage_mod.contains(pattern),
|
||||
"handlers/admin/observability/usage/mod.rs should stay as a thin router for {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let analytics_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/usage/analytics_routes.rs",
|
||||
);
|
||||
assert!(
|
||||
analytics_routes.contains("use super::analytics::{"),
|
||||
"usage/analytics_routes.rs should depend on analytics boundary directly"
|
||||
);
|
||||
assert!(
|
||||
analytics_routes.contains("use super::helpers::{"),
|
||||
"usage/analytics_routes.rs should depend on helpers boundary directly"
|
||||
);
|
||||
|
||||
let summary_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs",
|
||||
);
|
||||
assert!(
|
||||
summary_routes.contains("use super::analytics::{"),
|
||||
"usage/summary_routes.rs should depend on analytics boundary directly"
|
||||
);
|
||||
assert!(
|
||||
summary_routes.contains("use super::helpers::{"),
|
||||
"usage/summary_routes.rs should depend on helpers boundary directly"
|
||||
);
|
||||
|
||||
let detail_routes = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/usage/detail_routes.rs",
|
||||
);
|
||||
assert!(
|
||||
detail_routes.contains("use super::analytics::{"),
|
||||
"usage/detail_routes.rs should depend on analytics boundary directly"
|
||||
);
|
||||
assert!(
|
||||
detail_routes.contains("use super::helpers::{"),
|
||||
"usage/detail_routes.rs should depend on helpers boundary directly"
|
||||
);
|
||||
|
||||
let replay =
|
||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs");
|
||||
assert!(
|
||||
replay.contains("use super::helpers::{"),
|
||||
"usage/replay.rs should depend on helpers boundary directly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_monitoring_snapshots_stay_app_local() {
|
||||
let monitoring_cache_types = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_types.rs",
|
||||
);
|
||||
for pattern in [
|
||||
"pub(super) struct AdminMonitoringCacheSnapshot",
|
||||
"pub(super) struct AdminMonitoringCacheAffinityRecord",
|
||||
] {
|
||||
assert!(
|
||||
monitoring_cache_types.contains(pattern),
|
||||
"monitoring/cache_types.rs should own {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
let monitoring_resilience = read_workspace_file(
|
||||
"apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience.rs",
|
||||
);
|
||||
assert!(
|
||||
monitoring_resilience.contains("AdminMonitoringResilienceSnapshot"),
|
||||
"monitoring/resilience.rs should keep resilience snapshot ownership locally"
|
||||
);
|
||||
assert!(
|
||||
monitoring_resilience.contains("struct AdminMonitoringResilienceSnapshot"),
|
||||
"monitoring/resilience.rs should define AdminMonitoringResilienceSnapshot locally"
|
||||
);
|
||||
|
||||
let data_system = read_workspace_file("crates/aether-data/src/repository/system.rs");
|
||||
assert!(
|
||||
!data_system.contains("AdminMonitoringCacheSnapshot")
|
||||
&& !data_system.contains("AdminMonitoringResilienceSnapshot"),
|
||||
"monitoring snapshots are admin view DTOs and should not move into aether-data"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user