mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: 全栈功能增强 - 扩展 provider/pool 管理、完善调度与数据层、重构前端 Pool 页面
后端: - 扩展 pool_admin payloads 和 provider query models,增强 endpoint key 管理 - 完善 scheduler-core 候选排序与请求候选逻辑 - 增强 usage-runtime 写入、provider-transport 网络层与 OAuth 刷新 - 改进 AI pipeline 响应转换与流式处理 - 扩展 global_models/provider_catalog 数据层查询能力 - 增强 video-tasks-core 多 provider 支持 - 新增大量集成测试覆盖 pool/keys/provider_query/frontdoor 前端: - 重构 PoolManagement 页面,拆分状态管理/对话框逻辑到独立模块 - 新增 poolAdvancedDialog/poolSchedulingDialog/poolManagementState/poolMobilePresentation 工具函数及测试 - 改进 Dialog 组件与 provider tabs 显示 部署: - 更新 Rust CI workflow 和 Dockerfile 构建配置 Closes #275 Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
@@ -285,7 +285,6 @@ pub(crate) async fn maybe_build_local_admin_billing_response(
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
match decision.route_kind.as_deref() {
|
||||
_ => Ok(Some(build_admin_billing_data_unavailable_response())),
|
||||
}
|
||||
let _ = decision.route_kind.as_deref();
|
||||
Ok(Some(build_admin_billing_data_unavailable_response()))
|
||||
}
|
||||
|
||||
@@ -246,9 +246,11 @@ pub(super) async fn build_admin_monitoring_cache_affinity_response(
|
||||
"缺少 user_identifier",
|
||||
));
|
||||
};
|
||||
let direct_api_key_by_id =
|
||||
admin_monitoring_list_export_api_key_records_by_ids(state, &[user_identifier.clone()])
|
||||
.await?;
|
||||
let direct_api_key_by_id = admin_monitoring_list_export_api_key_records_by_ids(
|
||||
state,
|
||||
std::slice::from_ref(&user_identifier),
|
||||
)
|
||||
.await?;
|
||||
let direct_affinity_keys =
|
||||
std::iter::once(user_identifier.clone()).collect::<std::collections::BTreeSet<_>>();
|
||||
let direct_affinities =
|
||||
|
||||
@@ -70,7 +70,12 @@ pub(super) async fn maybe_handle(
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Some(
|
||||
Json(state.build_admin_provider_key_response(&created, now_unix_secs)).into_response(),
|
||||
Json(state.build_admin_provider_key_response(
|
||||
&created,
|
||||
&provider.provider_type,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,12 @@ pub(super) async fn maybe_handle(
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Some(
|
||||
Json(state.build_admin_provider_key_response(&updated, now_unix_secs)).into_response(),
|
||||
Json(state.build_admin_provider_key_response(
|
||||
&updated,
|
||||
&provider.provider_type,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,820 @@
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
AdminProviderPoolConfig, AdminProviderPoolRuntimeState,
|
||||
};
|
||||
use aether_admin::provider::pool as admin_provider_pool_pure;
|
||||
use crate::handlers::admin::shared::{
|
||||
provider_key_status_snapshot_payload, unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn admin_pool_api_formats(key: &StoredProviderCatalogKey) -> Vec<String> {
|
||||
key.api_formats
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn admin_pool_string_list(value: Option<&serde_json::Value>) -> Option<Vec<String>> {
|
||||
let values = value
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values)
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_json_object(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
||||
value
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned()
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn admin_pool_json_to_f64(value: Option<&serde_json::Value>) -> Option<f64> {
|
||||
let parsed = match value {
|
||||
Some(serde_json::Value::Number(number)) => number.as_f64(),
|
||||
Some(serde_json::Value::String(text)) => text.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}?;
|
||||
if parsed.is_finite() {
|
||||
Some(parsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_json_to_u64(value: Option<&serde_json::Value>) -> Option<u64> {
|
||||
let mut parsed = match value {
|
||||
Some(serde_json::Value::Number(number)) => number.as_f64(),
|
||||
Some(serde_json::Value::String(text)) => text.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}?;
|
||||
if !parsed.is_finite() || parsed <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if parsed > 1_000_000_000_000.0 {
|
||||
parsed /= 1000.0;
|
||||
}
|
||||
Some(parsed.floor() as u64)
|
||||
}
|
||||
|
||||
fn admin_pool_trimmed_string(value: Option<&serde_json::Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn admin_pool_trimmed_string_from_map(
|
||||
value: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
admin_pool_trimmed_string(value.and_then(|object| object.get(field)))
|
||||
}
|
||||
|
||||
fn admin_pool_oauth_organizations(
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
auth_config
|
||||
.and_then(|config| config.get("organizations"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn admin_pool_normalize_oauth_plan_type(value: &str, provider_type: &str) -> Option<String> {
|
||||
let mut normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
|
||||
normalized = normalized[provider_type.len()..]
|
||||
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let normalized = normalized.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_derive_oauth_expires_at(
|
||||
key: &StoredProviderCatalogKey,
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<u64> {
|
||||
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||
return None;
|
||||
}
|
||||
|
||||
for field in ["expires_at", "expiresAt", "expiry", "exp"] {
|
||||
let expires_at = admin_pool_json_to_u64(auth_config.and_then(|config| config.get(field)));
|
||||
if expires_at.is_some() {
|
||||
return expires_at;
|
||||
}
|
||||
}
|
||||
|
||||
key.expires_at_unix_secs
|
||||
}
|
||||
|
||||
fn admin_pool_derive_oauth_plan_type(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<String> {
|
||||
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(upstream_metadata) = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
let provider_bucket = upstream_metadata
|
||||
.get(&provider_type.trim().to_ascii_lowercase())
|
||||
.and_then(serde_json::Value::as_object);
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
for field in [
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
] {
|
||||
if let Some(value) = source.get(field).and_then(serde_json::Value::as_str) {
|
||||
let normalized = admin_pool_normalize_oauth_plan_type(value, provider_type);
|
||||
if normalized.is_some() {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(config) = auth_config {
|
||||
for field in ["plan_type", "tier", "plan", "subscription_plan"] {
|
||||
if let Some(value) = config.get(field).and_then(serde_json::Value::as_str) {
|
||||
let normalized = admin_pool_normalize_oauth_plan_type(value, provider_type);
|
||||
if normalized.is_some() {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn admin_pool_format_percent(value: f64) -> String {
|
||||
format!("{:.1}%", value.clamp(0.0, 100.0))
|
||||
}
|
||||
|
||||
fn admin_pool_format_quota_value(value: f64) -> String {
|
||||
let rounded = value.round();
|
||||
if (value - rounded).abs() < 1e-6 {
|
||||
rounded.to_string()
|
||||
} else {
|
||||
format!("{value:.1}")
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_has_quota_consumption(used_percent: Option<f64>) -> bool {
|
||||
used_percent
|
||||
.map(|value| value.clamp(0.0, 100.0) > 1e-6)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn admin_pool_format_reset_after(seconds: f64) -> Option<String> {
|
||||
if !seconds.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total_seconds = seconds.floor() as i64;
|
||||
if total_seconds <= 0 {
|
||||
return Some("已重置".to_string());
|
||||
}
|
||||
|
||||
let days = total_seconds / 86_400;
|
||||
let hours = (total_seconds % 86_400) / 3_600;
|
||||
let minutes = (total_seconds % 3_600) / 60;
|
||||
|
||||
if days > 0 {
|
||||
return Some(format!("{days}天{hours}小时后重置"));
|
||||
}
|
||||
if hours > 0 {
|
||||
return Some(format!("{hours}小时{minutes}分钟后重置"));
|
||||
}
|
||||
if minutes > 0 {
|
||||
return Some(format!("{minutes}分钟后重置"));
|
||||
}
|
||||
Some("即将重置".to_string())
|
||||
}
|
||||
|
||||
fn admin_pool_build_codex_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
fn codex_reset_seconds(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
reset_seconds_key: &str,
|
||||
reset_after_seconds_key: &str,
|
||||
reset_at_key: &str,
|
||||
) -> Option<f64> {
|
||||
admin_pool_json_to_f64(data.get(reset_seconds_key))
|
||||
.or_else(|| admin_pool_json_to_f64(data.get(reset_after_seconds_key)))
|
||||
.or_else(|| {
|
||||
let reset_at = admin_pool_json_to_u64(data.get(reset_at_key))?;
|
||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
Some(reset_at.saturating_sub(now_unix_secs) as f64)
|
||||
})
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
|
||||
let primary_used = admin_pool_json_to_f64(data.get("primary_used_percent"));
|
||||
if let Some(primary_used) = primary_used {
|
||||
let mut part = format!("周剩余 {}", admin_pool_format_percent(100.0 - primary_used));
|
||||
if admin_pool_has_quota_consumption(Some(primary_used)) {
|
||||
if let Some(reset_text) = codex_reset_seconds(
|
||||
data,
|
||||
"primary_reset_seconds",
|
||||
"primary_reset_after_seconds",
|
||||
"primary_reset_at",
|
||||
)
|
||||
.and_then(admin_pool_format_reset_after)
|
||||
{
|
||||
part.push_str(&format!(" ({reset_text})"));
|
||||
}
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
let secondary_used = admin_pool_json_to_f64(data.get("secondary_used_percent"));
|
||||
if let Some(secondary_used) = secondary_used {
|
||||
let mut part = format!(
|
||||
"5H剩余 {}",
|
||||
admin_pool_format_percent(100.0 - secondary_used)
|
||||
);
|
||||
if admin_pool_has_quota_consumption(Some(secondary_used)) {
|
||||
if let Some(reset_text) = codex_reset_seconds(
|
||||
data,
|
||||
"secondary_reset_seconds",
|
||||
"secondary_reset_after_seconds",
|
||||
"secondary_reset_at",
|
||||
)
|
||||
.and_then(admin_pool_format_reset_after)
|
||||
{
|
||||
part.push_str(&format!(" ({reset_text})"));
|
||||
}
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
return Some(parts.join(" | "));
|
||||
}
|
||||
|
||||
let has_credits = data
|
||||
.get("has_credits")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let credits_balance = admin_pool_json_to_f64(data.get("credits_balance"));
|
||||
if has_credits && credits_balance.is_some() {
|
||||
return credits_balance.map(|value| format!("积分 {value:.2}"));
|
||||
}
|
||||
if has_credits {
|
||||
return Some("有积分".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn admin_pool_build_kiro_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
if data
|
||||
.get("is_banned")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some("账号已封禁".to_string());
|
||||
}
|
||||
|
||||
let usage_percentage = admin_pool_json_to_f64(data.get("usage_percentage"));
|
||||
if let Some(usage_percentage) = usage_percentage {
|
||||
let remaining = 100.0 - usage_percentage;
|
||||
let current_usage = admin_pool_json_to_f64(data.get("current_usage"));
|
||||
let usage_limit = admin_pool_json_to_f64(data.get("usage_limit"));
|
||||
if let (Some(current_usage), Some(usage_limit)) = (current_usage, usage_limit) {
|
||||
if usage_limit > 0.0 {
|
||||
return Some(format!(
|
||||
"剩余 {} ({}/{})",
|
||||
admin_pool_format_percent(remaining),
|
||||
admin_pool_format_quota_value(current_usage),
|
||||
admin_pool_format_quota_value(usage_limit),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Some(format!("剩余 {}", admin_pool_format_percent(remaining)));
|
||||
}
|
||||
|
||||
let remaining = admin_pool_json_to_f64(data.get("remaining"));
|
||||
let usage_limit = admin_pool_json_to_f64(data.get("usage_limit"));
|
||||
match (remaining, usage_limit) {
|
||||
(Some(remaining), Some(usage_limit)) if usage_limit > 0.0 => Some(format!(
|
||||
"剩余 {}/{}",
|
||||
admin_pool_format_quota_value(remaining),
|
||||
admin_pool_format_quota_value(usage_limit),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_quota_by_model(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
data.get("quota_by_model")?.as_object()
|
||||
}
|
||||
|
||||
fn admin_pool_build_antigravity_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
if data
|
||||
.get("is_forbidden")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some("访问受限".to_string());
|
||||
}
|
||||
|
||||
let remaining_list = admin_pool_quota_by_model(data)?
|
||||
.values()
|
||||
.filter_map(serde_json::Value::as_object)
|
||||
.filter_map(|item| {
|
||||
let used_percent = admin_pool_json_to_f64(item.get("used_percent")).or_else(|| {
|
||||
admin_pool_json_to_f64(item.get("remaining_fraction"))
|
||||
.map(|value| (1.0 - value) * 100.0)
|
||||
})?;
|
||||
Some((100.0 - used_percent).clamp(0.0, 100.0))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if remaining_list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let min_remaining = remaining_list.iter().copied().fold(100.0_f64, f64::min);
|
||||
if remaining_list.len() == 1 {
|
||||
return Some(format!("剩余 {}", admin_pool_format_percent(min_remaining)));
|
||||
}
|
||||
Some(format!(
|
||||
"最低剩余 {} ({} 模型)",
|
||||
admin_pool_format_percent(min_remaining),
|
||||
remaining_list.len()
|
||||
))
|
||||
}
|
||||
|
||||
fn admin_pool_gemini_reset_at(item: &serde_json::Map<String, serde_json::Value>) -> Option<i64> {
|
||||
let reset_at = admin_pool_json_to_u64(item.get("reset_at"))?;
|
||||
Some(reset_at as i64)
|
||||
}
|
||||
|
||||
fn admin_pool_gemini_model_exhausted(item: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
if item
|
||||
.get("is_exhausted")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if admin_pool_json_to_f64(item.get("remaining_fraction")).is_some_and(|value| value <= 0.0) {
|
||||
return true;
|
||||
}
|
||||
admin_pool_json_to_f64(item.get("used_percent")).is_some_and(|value| value >= 100.0 - 1e-6)
|
||||
}
|
||||
|
||||
fn admin_pool_build_gemini_cli_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let mut active = admin_pool_quota_by_model(data)?
|
||||
.iter()
|
||||
.filter_map(|(model_name, item)| {
|
||||
let item = item.as_object()?;
|
||||
if !admin_pool_gemini_model_exhausted(item) {
|
||||
return None;
|
||||
}
|
||||
let reset_at = admin_pool_gemini_reset_at(item);
|
||||
if reset_at.is_some_and(|value| value <= now) {
|
||||
return None;
|
||||
}
|
||||
Some((model_name.as_str(), reset_at))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if active.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
active.sort_by_key(|(_, reset_at)| reset_at.unwrap_or(i64::MAX));
|
||||
let (first_model, first_reset_at) = active[0];
|
||||
if active.len() == 1 {
|
||||
if let Some(reset_at) = first_reset_at {
|
||||
if let Some(reset_text) = admin_pool_format_reset_after((reset_at - now) as f64) {
|
||||
return Some(format!("{first_model} 冷却中 ({reset_text})"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{first_model} 冷却中"));
|
||||
}
|
||||
|
||||
if let Some(reset_at) = first_reset_at {
|
||||
if let Some(reset_text) = admin_pool_format_reset_after((reset_at - now) as f64) {
|
||||
return Some(format!(
|
||||
"{} 个模型冷却中(最早 {reset_text})",
|
||||
active.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(format!("{} 个模型冷却中", active.len()))
|
||||
}
|
||||
|
||||
fn admin_pool_build_account_quota(
|
||||
provider_type: &str,
|
||||
upstream_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
let upstream_metadata = upstream_metadata?.as_object()?;
|
||||
let data = upstream_metadata
|
||||
.get(&normalized_provider_type)?
|
||||
.as_object()?;
|
||||
|
||||
match normalized_provider_type.as_str() {
|
||||
"codex" => admin_pool_build_codex_account_quota(data),
|
||||
"kiro" => admin_pool_build_kiro_account_quota(data),
|
||||
"antigravity" => admin_pool_build_antigravity_account_quota(data),
|
||||
"gemini_cli" => admin_pool_build_gemini_cli_account_quota(data),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_health_score(key: &StoredProviderCatalogKey) -> f64 {
|
||||
let scores = key
|
||||
.health_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|formats| {
|
||||
formats
|
||||
.values()
|
||||
.filter_map(serde_json::Value::as_object)
|
||||
.filter_map(|item| item.get("health_score"))
|
||||
.filter_map(serde_json::Value::as_f64)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if scores.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
scores.into_iter().fold(1.0, f64::min)
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_circuit_breaker_open(key: &StoredProviderCatalogKey) -> bool {
|
||||
key.circuit_breaker_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|formats| {
|
||||
formats
|
||||
.values()
|
||||
.filter_map(serde_json::Value::as_object)
|
||||
.any(|item| {
|
||||
item.get("open")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn admin_pool_scheduling_payload(
|
||||
key: &StoredProviderCatalogKey,
|
||||
cooldown_reason: Option<&str>,
|
||||
cooldown_ttl_seconds: Option<u64>,
|
||||
health_score: f64,
|
||||
circuit_breaker_open: bool,
|
||||
) -> (String, String, String, Vec<serde_json::Value>) {
|
||||
if !key.is_active {
|
||||
return (
|
||||
"blocked".to_string(),
|
||||
"inactive".to_string(),
|
||||
"已禁用".to_string(),
|
||||
vec![json!({
|
||||
"code": "inactive",
|
||||
"label": "已禁用",
|
||||
"blocking": true,
|
||||
"source": "manual",
|
||||
"ttl_seconds": serde_json::Value::Null,
|
||||
"detail": serde_json::Value::Null,
|
||||
})],
|
||||
);
|
||||
}
|
||||
if let Some(reason) = cooldown_reason {
|
||||
return (
|
||||
"degraded".to_string(),
|
||||
"cooldown".to_string(),
|
||||
"冷却中".to_string(),
|
||||
vec![json!({
|
||||
"code": "cooldown",
|
||||
"label": "冷却中",
|
||||
"blocking": true,
|
||||
"source": "pool",
|
||||
"ttl_seconds": cooldown_ttl_seconds,
|
||||
"detail": reason,
|
||||
})],
|
||||
);
|
||||
}
|
||||
if circuit_breaker_open {
|
||||
return (
|
||||
"degraded".to_string(),
|
||||
"circuit_breaker".to_string(),
|
||||
"熔断中".to_string(),
|
||||
vec![json!({
|
||||
"code": "circuit_breaker",
|
||||
"label": "熔断中",
|
||||
"blocking": true,
|
||||
"source": "health",
|
||||
"ttl_seconds": serde_json::Value::Null,
|
||||
"detail": serde_json::Value::Null,
|
||||
})],
|
||||
);
|
||||
}
|
||||
if health_score < 0.5 {
|
||||
return (
|
||||
"degraded".to_string(),
|
||||
"health_low".to_string(),
|
||||
"健康度较低".to_string(),
|
||||
vec![json!({
|
||||
"code": "health_low",
|
||||
"label": "健康度较低",
|
||||
"blocking": false,
|
||||
"source": "health",
|
||||
"ttl_seconds": serde_json::Value::Null,
|
||||
"detail": serde_json::Value::Null,
|
||||
})],
|
||||
);
|
||||
}
|
||||
(
|
||||
"available".to_string(),
|
||||
"available".to_string(),
|
||||
"可用".to_string(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
pub(super) fn build_admin_pool_key_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_type: &str,
|
||||
key: &StoredProviderCatalogKey,
|
||||
runtime: &AdminProviderPoolRuntimeState,
|
||||
pool_config: Option<AdminProviderPoolConfig>,
|
||||
) -> serde_json::Value {
|
||||
admin_provider_pool_pure::build_admin_pool_key_payload(
|
||||
key,
|
||||
&admin_provider_pool_pure::AdminPoolKeyPayloadContext {
|
||||
cooldown_reason: runtime.cooldown_reason_by_key.get(&key.id).cloned(),
|
||||
cooldown_ttl_seconds: runtime
|
||||
.cooldown_reason_by_key
|
||||
.get(&key.id)
|
||||
.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),
|
||||
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(),
|
||||
cost_limit: pool_config.and_then(|config| config.cost_limit_per_key_tokens),
|
||||
},
|
||||
)
|
||||
let cooldown_reason = runtime.cooldown_reason_by_key.get(&key.id).cloned();
|
||||
let cooldown_ttl_seconds = cooldown_reason
|
||||
.as_ref()
|
||||
.and_then(|_| runtime.cooldown_ttl_by_key.get(&key.id).copied());
|
||||
let health_score = admin_pool_health_score(key);
|
||||
let circuit_breaker_open = admin_pool_circuit_breaker_open(key);
|
||||
let (scheduling_status, scheduling_reason, scheduling_label, scheduling_reasons) =
|
||||
admin_pool_scheduling_payload(
|
||||
key,
|
||||
cooldown_reason.as_deref(),
|
||||
cooldown_ttl_seconds,
|
||||
health_score,
|
||||
circuit_breaker_open,
|
||||
);
|
||||
let auth_config = state.parse_catalog_auth_config_json(key);
|
||||
let oauth_expires_at = admin_pool_derive_oauth_expires_at(key, auth_config.as_ref());
|
||||
let oauth_plan_type =
|
||||
admin_pool_derive_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
||||
let status_snapshot = provider_key_status_snapshot_payload(key);
|
||||
let account_snapshot = status_snapshot
|
||||
.get("account")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let quota_snapshot = status_snapshot
|
||||
.get("quota")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let oauth_snapshot = status_snapshot
|
||||
.get("oauth")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let quota_updated_at =
|
||||
admin_pool_json_to_u64(quota_snapshot.and_then(|item| item.get("updated_at")));
|
||||
let oauth_invalid_at =
|
||||
admin_pool_json_to_u64(oauth_snapshot.and_then(|item| item.get("invalid_at")))
|
||||
.or(key.oauth_invalid_at_unix_secs);
|
||||
let oauth_account_id = admin_pool_trimmed_string_from_map(auth_config.as_ref(), "account_id");
|
||||
let oauth_account_name =
|
||||
admin_pool_trimmed_string_from_map(auth_config.as_ref(), "account_name");
|
||||
let oauth_account_user_id =
|
||||
admin_pool_trimmed_string_from_map(auth_config.as_ref(), "account_user_id");
|
||||
let oauth_organizations = admin_pool_oauth_organizations(auth_config.as_ref());
|
||||
let account_status_code = admin_pool_trimmed_string_from_map(account_snapshot, "code");
|
||||
let account_status_label =
|
||||
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("label")));
|
||||
let account_status_reason =
|
||||
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("reason")));
|
||||
let account_status_blocked = account_snapshot
|
||||
.and_then(|item| item.get("blocked"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let account_status_recoverable = account_snapshot
|
||||
.and_then(|item| item.get("recoverable"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let account_status_source =
|
||||
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("source")));
|
||||
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert("key_id".to_string(), json!(key.id));
|
||||
payload.insert("key_name".to_string(), json!(key.name));
|
||||
payload.insert("is_active".to_string(), json!(key.is_active));
|
||||
payload.insert("auth_type".to_string(), json!(key.auth_type));
|
||||
payload.insert("oauth_expires_at".to_string(), json!(oauth_expires_at));
|
||||
payload.insert("oauth_invalid_at".to_string(), json!(oauth_invalid_at));
|
||||
payload.insert(
|
||||
"oauth_invalid_reason".to_string(),
|
||||
json!(key.oauth_invalid_reason),
|
||||
);
|
||||
payload.insert("oauth_plan_type".to_string(), json!(oauth_plan_type));
|
||||
payload.insert("oauth_account_id".to_string(), json!(oauth_account_id));
|
||||
payload.insert("oauth_account_name".to_string(), json!(oauth_account_name));
|
||||
payload.insert(
|
||||
"oauth_account_user_id".to_string(),
|
||||
json!(oauth_account_user_id),
|
||||
);
|
||||
payload.insert(
|
||||
"oauth_organizations".to_string(),
|
||||
serde_json::Value::Array(oauth_organizations),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_code".to_string(),
|
||||
json!(account_status_code),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_label".to_string(),
|
||||
json!(account_status_label),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_reason".to_string(),
|
||||
json!(account_status_reason),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_blocked".to_string(),
|
||||
json!(account_status_blocked),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_recoverable".to_string(),
|
||||
json!(account_status_recoverable),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_source".to_string(),
|
||||
json!(account_status_source),
|
||||
);
|
||||
payload.insert("status_snapshot".to_string(), status_snapshot);
|
||||
payload.insert("quota_updated_at".to_string(), json!(quota_updated_at));
|
||||
payload.insert("health_score".to_string(), json!(health_score));
|
||||
payload.insert(
|
||||
"circuit_breaker_open".to_string(),
|
||||
json!(circuit_breaker_open),
|
||||
);
|
||||
payload.insert(
|
||||
"api_formats".to_string(),
|
||||
json!(admin_pool_api_formats(key)),
|
||||
);
|
||||
payload.insert(
|
||||
"rate_multipliers".to_string(),
|
||||
json!(admin_pool_json_object(key.rate_multipliers.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"internal_priority".to_string(),
|
||||
json!(key.internal_priority),
|
||||
);
|
||||
payload.insert("rpm_limit".to_string(), json!(key.rpm_limit));
|
||||
payload.insert(
|
||||
"cache_ttl_minutes".to_string(),
|
||||
json!(key.cache_ttl_minutes),
|
||||
);
|
||||
payload.insert(
|
||||
"max_probe_interval_minutes".to_string(),
|
||||
json!(key.max_probe_interval_minutes),
|
||||
);
|
||||
payload.insert("note".to_string(), json!(key.note));
|
||||
payload.insert(
|
||||
"allowed_models".to_string(),
|
||||
json!(admin_pool_string_list(key.allowed_models.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"capabilities".to_string(),
|
||||
json!(admin_pool_json_object(key.capabilities.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"auto_fetch_models".to_string(),
|
||||
json!(key.auto_fetch_models),
|
||||
);
|
||||
payload.insert(
|
||||
"locked_models".to_string(),
|
||||
json!(admin_pool_string_list(key.locked_models.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"model_include_patterns".to_string(),
|
||||
json!(admin_pool_string_list(key.model_include_patterns.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"model_exclude_patterns".to_string(),
|
||||
json!(admin_pool_string_list(key.model_exclude_patterns.as_ref())),
|
||||
);
|
||||
payload.insert("proxy".to_string(), json!(key.proxy.clone()));
|
||||
payload.insert("fingerprint".to_string(), json!(key.fingerprint.clone()));
|
||||
payload.insert(
|
||||
"account_quota".to_string(),
|
||||
json!(admin_pool_build_account_quota(
|
||||
provider_type,
|
||||
key.upstream_metadata.as_ref(),
|
||||
)),
|
||||
);
|
||||
payload.insert("cooldown_reason".to_string(), json!(cooldown_reason));
|
||||
payload.insert(
|
||||
"cooldown_ttl_seconds".to_string(),
|
||||
json!(cooldown_ttl_seconds),
|
||||
);
|
||||
payload.insert(
|
||||
"cost_window_usage".to_string(),
|
||||
json!(runtime
|
||||
.cost_window_usage_by_key
|
||||
.get(&key.id)
|
||||
.copied()
|
||||
.unwrap_or(0)),
|
||||
);
|
||||
payload.insert(
|
||||
"cost_limit".to_string(),
|
||||
json!(pool_config.map(|config| config.cost_limit_per_key_tokens)),
|
||||
);
|
||||
payload.insert(
|
||||
"request_count".to_string(),
|
||||
json!(key.request_count.unwrap_or(0)),
|
||||
);
|
||||
payload.insert("total_tokens".to_string(), json!(key.total_tokens));
|
||||
payload.insert(
|
||||
"total_cost_usd".to_string(),
|
||||
json!(format!("{:.8}", key.total_cost_usd)),
|
||||
);
|
||||
payload.insert(
|
||||
"sticky_sessions".to_string(),
|
||||
json!(runtime
|
||||
.sticky_sessions_by_key
|
||||
.get(&key.id)
|
||||
.copied()
|
||||
.unwrap_or(0)),
|
||||
);
|
||||
payload.insert(
|
||||
"lru_score".to_string(),
|
||||
json!(runtime.lru_score_by_key.get(&key.id).copied()),
|
||||
);
|
||||
payload.insert(
|
||||
"created_at".to_string(),
|
||||
json!(key.created_at_unix_secs.and_then(unix_secs_to_rfc3339)),
|
||||
);
|
||||
payload.insert(
|
||||
"last_used_at".to_string(),
|
||||
json!(key.last_used_at_unix_secs.and_then(unix_secs_to_rfc3339)),
|
||||
);
|
||||
payload.insert("scheduling_status".to_string(), json!(scheduling_status));
|
||||
payload.insert("scheduling_reason".to_string(), json!(scheduling_reason));
|
||||
payload.insert("scheduling_label".to_string(), json!(scheduling_label));
|
||||
payload.insert("scheduling_reasons".to_string(), json!(scheduling_reasons));
|
||||
|
||||
serde_json::Value::Object(payload)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,15 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
||||
|
||||
let items = keys
|
||||
.into_iter()
|
||||
.map(|key| pool_payloads::build_admin_pool_key_payload(&key, &runtime, pool_config))
|
||||
.map(|key| {
|
||||
pool_payloads::build_admin_pool_key_payload(
|
||||
state,
|
||||
&provider.provider_type,
|
||||
&key,
|
||||
&runtime,
|
||||
pool_config,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(json!({
|
||||
|
||||
@@ -48,6 +48,33 @@ fn admin_pool_derive_oauth_plan_type(
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(upstream_metadata) = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
let provider_bucket = upstream_metadata
|
||||
.get(&provider_type.trim().to_ascii_lowercase())
|
||||
.and_then(serde_json::Value::as_object);
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
for plan_key in [
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
] {
|
||||
if let Some(value) = source.get(plan_key).and_then(serde_json::Value::as_str) {
|
||||
if let Some(normalized) = normalize(value) {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(auth_config) = admin_pool_parse_auth_config_json(state, key) {
|
||||
for plan_key in ["plan_type", "tier", "plan", "subscription_plan"] {
|
||||
if let Some(value) = auth_config
|
||||
@@ -61,28 +88,6 @@ fn admin_pool_derive_oauth_plan_type(
|
||||
}
|
||||
}
|
||||
|
||||
let upstream_metadata = key.upstream_metadata.as_ref()?.as_object()?;
|
||||
let provider_bucket = upstream_metadata
|
||||
.get(&provider_type.trim().to_ascii_lowercase())
|
||||
.and_then(serde_json::Value::as_object);
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
for plan_key in [
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
] {
|
||||
if let Some(value) = source.get(plan_key).and_then(serde_json::Value::as_str) {
|
||||
if let Some(normalized) = normalize(value) {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +1,223 @@
|
||||
use super::payload::{provider_query_extract_api_key_id, provider_query_extract_provider_id};
|
||||
use super::payload::{
|
||||
provider_query_extract_api_key_id, provider_query_extract_force_refresh,
|
||||
provider_query_extract_provider_id,
|
||||
};
|
||||
use super::response::{
|
||||
build_admin_provider_query_bad_request_response, build_admin_provider_query_not_found_response,
|
||||
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_ID_REQUIRED_DETAIL,
|
||||
ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL,
|
||||
};
|
||||
use crate::execution_runtime;
|
||||
use crate::model_fetch::ModelFetchRuntimeState;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_model_fetch::{
|
||||
aggregate_models_for_cache, build_models_fetch_execution_plan, extract_error_message,
|
||||
parse_models_response,
|
||||
};
|
||||
use axum::{body::Body, http::Response, response::IntoResponse, Json};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const ADMIN_PROVIDER_QUERY_LOCAL_TEST_MODEL_MESSAGE: &str =
|
||||
"Rust local provider-query model test is not configured";
|
||||
pub(crate) const ADMIN_PROVIDER_QUERY_LOCAL_TEST_MODEL_FAILOVER_MESSAGE: &str =
|
||||
"Rust local provider-query failover simulation is not configured";
|
||||
const ADMIN_PROVIDER_QUERY_NO_ACTIVE_ENDPOINT_DETAIL: &str =
|
||||
"No active endpoints found for this provider";
|
||||
const ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL: &str =
|
||||
"No models returned from any endpoint";
|
||||
const PROVIDER_QUERY_FETCH_FORMAT_PRIORITY: &[&[&str]] = &[
|
||||
&["openai:chat", "openai:cli", "openai:compact"],
|
||||
&["claude:chat", "claude:cli"],
|
||||
&["gemini:chat", "gemini:cli"],
|
||||
];
|
||||
|
||||
fn provider_query_string_list(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||
value
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
#[derive(Debug)]
|
||||
struct ProviderQueryKeyFetchResult {
|
||||
models: Vec<Value>,
|
||||
error: Option<String>,
|
||||
from_cache: bool,
|
||||
}
|
||||
|
||||
fn provider_query_resolved_api_formats(
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
selected_key: Option<&StoredProviderCatalogKey>,
|
||||
) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let key_formats = selected_key
|
||||
.map(|key| provider_query_string_list(key.api_formats.as_ref()))
|
||||
.unwrap_or_default();
|
||||
let mut formats = Vec::new();
|
||||
fn provider_query_provider_payload(provider: &StoredProviderCatalogProvider) -> Value {
|
||||
json!({
|
||||
"id": provider.id.clone(),
|
||||
"name": provider.name.clone(),
|
||||
"display_name": provider.name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_query_key_display_name(key: &StoredProviderCatalogKey) -> String {
|
||||
let trimmed = key.name.trim();
|
||||
if trimmed.is_empty() {
|
||||
key.id.clone()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_query_normalize_api_format(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn provider_query_selected_fetch_endpoints(
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
) -> Vec<StoredProviderCatalogEndpoint> {
|
||||
let mut by_format = BTreeMap::<String, StoredProviderCatalogEndpoint>::new();
|
||||
for endpoint in endpoints.iter().filter(|endpoint| endpoint.is_active) {
|
||||
let api_format = endpoint.api_format.trim();
|
||||
let api_format = provider_query_normalize_api_format(&endpoint.api_format);
|
||||
if api_format.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !key_formats.is_empty() && !key_formats.iter().any(|value| value == api_format) {
|
||||
by_format.insert(api_format, endpoint.clone());
|
||||
}
|
||||
|
||||
// 与 Python 版本保持一致:同族优先使用 chat 端点,其次才回退到 cli/compact。
|
||||
PROVIDER_QUERY_FETCH_FORMAT_PRIORITY
|
||||
.iter()
|
||||
.filter_map(|candidates| {
|
||||
candidates
|
||||
.iter()
|
||||
.find_map(|api_format| by_format.get(*api_format))
|
||||
.cloned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn provider_query_read_cached_models(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) -> Option<Vec<Value>> {
|
||||
let runner = state.redis_kv_runner()?;
|
||||
let cache_key = runner
|
||||
.keyspace()
|
||||
.key(&format!("upstream_models:{provider_id}:{key_id}"));
|
||||
let mut connection = runner
|
||||
.client()
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.ok()?;
|
||||
let raw = redis::cmd("GET")
|
||||
.arg(&cache_key)
|
||||
.query_async::<Option<String>>(&mut connection)
|
||||
.await
|
||||
.ok()??;
|
||||
let parsed = serde_json::from_str::<Vec<Value>>(&raw).ok()?;
|
||||
Some(aggregate_models_for_cache(&parsed))
|
||||
}
|
||||
|
||||
async fn provider_query_fetch_models_from_transport(
|
||||
state: &AppState,
|
||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
||||
) -> Result<Vec<Value>, String> {
|
||||
let plan = build_models_fetch_execution_plan(state, transport).await?;
|
||||
let result = execution_runtime::execute_execution_runtime_sync_plan(state, None, &plan)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if result.status_code != 200 {
|
||||
let message = result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
.and_then(extract_error_message)
|
||||
.or_else(|| {
|
||||
result.error.as_ref().and_then(|error| {
|
||||
let message = error.message.trim();
|
||||
(!message.is_empty()).then_some(message.to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| format!("upstream returned status {}", result.status_code));
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
let body_json = result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
.ok_or_else(|| "models fetch response body is missing JSON payload".to_string())?;
|
||||
let parsed = parse_models_response(&transport.endpoint.api_format, body_json)?;
|
||||
Ok(parsed.cached_models)
|
||||
}
|
||||
|
||||
async fn provider_query_fetch_models_for_key(
|
||||
state: &AppState,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
key: &StoredProviderCatalogKey,
|
||||
force_refresh: bool,
|
||||
) -> Result<ProviderQueryKeyFetchResult, GatewayError> {
|
||||
if !force_refresh {
|
||||
if let Some(cached_models) =
|
||||
provider_query_read_cached_models(state, &provider.id, &key.id).await
|
||||
{
|
||||
return Ok(ProviderQueryKeyFetchResult {
|
||||
models: cached_models,
|
||||
error: None,
|
||||
from_cache: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let selected_endpoints = provider_query_selected_fetch_endpoints(endpoints);
|
||||
if selected_endpoints.is_empty() {
|
||||
return Ok(ProviderQueryKeyFetchResult {
|
||||
models: Vec::new(),
|
||||
error: Some(ADMIN_PROVIDER_QUERY_NO_ACTIVE_ENDPOINT_DETAIL.to_string()),
|
||||
from_cache: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut all_models = Vec::new();
|
||||
let mut all_errors = Vec::new();
|
||||
for endpoint in selected_endpoints {
|
||||
let Some(transport) = state
|
||||
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
|
||||
.await?
|
||||
else {
|
||||
all_errors.push(format!(
|
||||
"{} transport snapshot unavailable",
|
||||
endpoint.api_format.trim()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if seen.insert(api_format.to_string()) {
|
||||
formats.push(api_format.to_string());
|
||||
};
|
||||
match provider_query_fetch_models_from_transport(state, &transport).await {
|
||||
Ok(models) => all_models.extend(models),
|
||||
Err(err) => all_errors.push(err),
|
||||
}
|
||||
}
|
||||
|
||||
if formats.is_empty() {
|
||||
for api_format in key_formats {
|
||||
if seen.insert(api_format.clone()) {
|
||||
formats.push(api_format);
|
||||
}
|
||||
}
|
||||
let unique_models = aggregate_models_for_cache(&all_models);
|
||||
if !unique_models.is_empty() {
|
||||
<AppState as ModelFetchRuntimeState>::write_upstream_models_cache(
|
||||
state,
|
||||
&provider.id,
|
||||
&key.id,
|
||||
&unique_models,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
formats
|
||||
let mut error = if all_errors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_errors.join("; "))
|
||||
};
|
||||
if unique_models.is_empty() && error.is_none() {
|
||||
error = Some(ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL.to_string());
|
||||
}
|
||||
|
||||
Ok(ProviderQueryKeyFetchResult {
|
||||
models: unique_models,
|
||||
error,
|
||||
from_cache: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_query_models_response(
|
||||
@@ -99,96 +251,89 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
.app()
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await?;
|
||||
let selected_key = if let Some(api_key_id) = provider_query_extract_api_key_id(payload) {
|
||||
let Some(key) = keys.iter().find(|key| key.id == api_key_id) else {
|
||||
let force_refresh = provider_query_extract_force_refresh(payload);
|
||||
|
||||
if let Some(api_key_id) = provider_query_extract_api_key_id(payload) {
|
||||
let Some(selected_key) = keys.iter().find(|key| key.id == api_key_id) else {
|
||||
return Ok(build_admin_provider_query_not_found_response(
|
||||
ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL,
|
||||
));
|
||||
};
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let active_keys = keys.iter().filter(|key| key.is_active).count();
|
||||
if selected_key.is_none() && active_keys == 0 {
|
||||
|
||||
let result = provider_query_fetch_models_for_key(
|
||||
state.app(),
|
||||
&provider,
|
||||
&endpoints,
|
||||
selected_key,
|
||||
force_refresh,
|
||||
)
|
||||
.await?;
|
||||
let success = !result.models.is_empty();
|
||||
return Ok(Json(json!({
|
||||
"success": success,
|
||||
"data": {
|
||||
"models": result.models,
|
||||
"error": result.error,
|
||||
"from_cache": result.from_cache,
|
||||
},
|
||||
"provider": provider_query_provider_payload(&provider),
|
||||
}))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let active_keys = keys.iter().filter(|key| key.is_active).collect::<Vec<_>>();
|
||||
if active_keys.is_empty() {
|
||||
return Ok(build_admin_provider_query_bad_request_response(
|
||||
ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL,
|
||||
));
|
||||
}
|
||||
let active_key_count = active_keys.len();
|
||||
|
||||
let resolved_api_formats = provider_query_resolved_api_formats(&endpoints, selected_key);
|
||||
let provider_models = state
|
||||
.app()
|
||||
.list_admin_provider_available_source_models(&provider.id)
|
||||
.await?;
|
||||
|
||||
let mut grouped: BTreeMap<
|
||||
String,
|
||||
(
|
||||
aether_data_contracts::repository::global_models::StoredAdminProviderModel,
|
||||
BTreeSet<String>,
|
||||
),
|
||||
> = BTreeMap::new();
|
||||
for model in provider_models {
|
||||
let entry = grouped
|
||||
.entry(model.provider_model_name.clone())
|
||||
.or_insert_with(|| (model.clone(), BTreeSet::new()));
|
||||
for api_format in &resolved_api_formats {
|
||||
entry.1.insert(api_format.clone());
|
||||
let mut all_models = Vec::new();
|
||||
let mut all_errors = Vec::new();
|
||||
let mut cache_hit_count = 0usize;
|
||||
let mut fetch_count = 0usize;
|
||||
for key in active_keys {
|
||||
let result =
|
||||
provider_query_fetch_models_for_key(state.app(), &provider, &endpoints, key, force_refresh)
|
||||
.await?;
|
||||
all_models.extend(result.models);
|
||||
if let Some(error) = result.error {
|
||||
all_errors.push(format!(
|
||||
"Key {}: {}",
|
||||
provider_query_key_display_name(key),
|
||||
error
|
||||
));
|
||||
}
|
||||
if result.from_cache {
|
||||
cache_hit_count += 1;
|
||||
} else {
|
||||
fetch_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let models: Vec<_> = grouped
|
||||
.into_iter()
|
||||
.map(|(model_id, (model, api_formats))| {
|
||||
let display_name = model
|
||||
.global_model_display_name
|
||||
.clone()
|
||||
.or(model.global_model_name.clone())
|
||||
.unwrap_or_else(|| model_id.clone());
|
||||
let api_formats: Vec<_> = api_formats.into_iter().collect();
|
||||
json!({
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"created": model.created_at_unix_secs,
|
||||
"owned_by": provider.name,
|
||||
"display_name": display_name,
|
||||
"api_format": api_formats.first().cloned(),
|
||||
"api_formats": api_formats,
|
||||
"provider_model_name": model.provider_model_name,
|
||||
"global_model_id": model.global_model_id,
|
||||
"global_model_name": model.global_model_name,
|
||||
"supports_streaming": model.supports_streaming,
|
||||
"supports_function_calling": model.supports_function_calling,
|
||||
"supports_vision": model.supports_vision,
|
||||
"supports_extended_thinking": model.supports_extended_thinking,
|
||||
"supports_image_generation": model.supports_image_generation,
|
||||
"is_available": model.is_available,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let models = aggregate_models_for_cache(&all_models);
|
||||
let success = !models.is_empty();
|
||||
let error = if success {
|
||||
let mut error = if all_errors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL)
|
||||
Some(all_errors.join("; "))
|
||||
};
|
||||
if !success && error.is_none() {
|
||||
error = Some("No models returned from any key".to_string());
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": success,
|
||||
"data": {
|
||||
"models": models,
|
||||
"error": error,
|
||||
"from_cache": true,
|
||||
"keys_total": active_keys,
|
||||
"keys_cached": 0,
|
||||
"keys_fetched": 0,
|
||||
},
|
||||
"provider": {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.name,
|
||||
"from_cache": fetch_count == 0 && cache_hit_count > 0,
|
||||
"keys_total": active_key_count,
|
||||
"keys_cached": cache_hit_count,
|
||||
"keys_fetched": fetch_count,
|
||||
},
|
||||
"provider": provider_query_provider_payload(&provider),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ pub(crate) fn provider_query_extract_api_key_id(payload: &serde_json::Value) ->
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_query_extract_force_refresh(payload: &serde_json::Value) -> bool {
|
||||
payload
|
||||
.get("force_refresh")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_query_extract_model(payload: &serde_json::Value) -> Option<String> {
|
||||
payload
|
||||
.get("model")
|
||||
|
||||
@@ -39,7 +39,9 @@ pub(crate) async fn build_admin_provider_keys_payload(
|
||||
keys.into_iter()
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.map(|key| state.build_admin_provider_key_response(&key, now_unix_secs))
|
||||
.map(|key| {
|
||||
state.build_admin_provider_key_response(&key, &provider.provider_type, now_unix_secs)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ impl<'a> AdminAppState<'a> {
|
||||
pub(crate) fn build_admin_provider_key_response(
|
||||
&self,
|
||||
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
crate::handlers::admin::shared::build_admin_provider_key_response(
|
||||
self.app,
|
||||
key,
|
||||
provider_type,
|
||||
now_unix_secs,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -672,8 +672,7 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
||||
providers.into_iter().next()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| None);
|
||||
};
|
||||
let provider = match provider {
|
||||
Some(provider) => provider,
|
||||
None => state
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
|
||||
use axum::{body::Body, response::Response};
|
||||
|
||||
use super::models_responses::{
|
||||
@@ -9,9 +10,74 @@ use super::models_responses::{
|
||||
build_models_not_found_response, build_openai_model_detail_response,
|
||||
build_openai_models_list_response,
|
||||
};
|
||||
use super::models_shared::{filter_rows_for_models, models_api_format, models_detail_id};
|
||||
use super::models_shared::{
|
||||
filter_rows_for_models, models_api_format, models_detail_id, models_query_api_formats,
|
||||
};
|
||||
use super::{query_param_value, AppState, GatewayPublicRequestContext};
|
||||
|
||||
fn sort_and_dedup_model_rows(
|
||||
mut rows: Vec<StoredMinimalCandidateSelectionRow>,
|
||||
) -> Vec<StoredMinimalCandidateSelectionRow> {
|
||||
rows.sort_by(|left, right| {
|
||||
left.global_model_name
|
||||
.cmp(&right.global_model_name)
|
||||
.then(left.provider_priority.cmp(&right.provider_priority))
|
||||
.then(left.key_internal_priority.cmp(&right.key_internal_priority))
|
||||
.then(left.provider_id.cmp(&right.provider_id))
|
||||
.then(left.endpoint_id.cmp(&right.endpoint_id))
|
||||
.then(left.key_id.cmp(&right.key_id))
|
||||
.then(left.model_id.cmp(&right.model_id))
|
||||
});
|
||||
let mut deduped = Vec::with_capacity(rows.len());
|
||||
let mut last_model_name: Option<String> = None;
|
||||
for row in rows {
|
||||
if last_model_name.as_deref() == Some(row.global_model_name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
last_model_name = Some(row.global_model_name.clone());
|
||||
deduped.push(row);
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
async fn list_model_rows_for_client_format(
|
||||
state: &AppState,
|
||||
api_format: &str,
|
||||
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
|
||||
) -> Option<Vec<StoredMinimalCandidateSelectionRow>> {
|
||||
let mut collected = Vec::new();
|
||||
for query_format in models_query_api_formats(api_format) {
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format(query_format)
|
||||
.await
|
||||
.ok()?;
|
||||
let mut filtered = filter_rows_for_models(rows, auth_snapshot, query_format);
|
||||
collected.append(&mut filtered);
|
||||
}
|
||||
Some(sort_and_dedup_model_rows(collected))
|
||||
}
|
||||
|
||||
async fn list_model_rows_for_client_format_and_global_model(
|
||||
state: &AppState,
|
||||
api_format: &str,
|
||||
global_model_name: &str,
|
||||
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
|
||||
) -> Option<Vec<StoredMinimalCandidateSelectionRow>> {
|
||||
let mut collected = Vec::new();
|
||||
for query_format in models_query_api_formats(api_format) {
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format_and_global_model(
|
||||
query_format,
|
||||
global_model_name,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let mut filtered = filter_rows_for_models(rows, auth_snapshot, query_format);
|
||||
collected.append(&mut filtered);
|
||||
}
|
||||
Some(sort_and_dedup_model_rows(collected))
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_models_route_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
@@ -44,11 +110,7 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
|
||||
match decision.route_kind.as_deref() {
|
||||
Some("list") => {
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format(api_format)
|
||||
.await
|
||||
.ok()?;
|
||||
let rows = filter_rows_for_models(rows, auth_snapshot, api_format);
|
||||
let rows = list_model_rows_for_client_format(state, api_format, auth_snapshot).await?;
|
||||
if rows.is_empty() {
|
||||
return Some(build_empty_models_list_response(api_format));
|
||||
}
|
||||
@@ -94,13 +156,13 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
}
|
||||
Some("detail") => {
|
||||
let model_id = models_detail_id(&request_context.request_path)?;
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format_and_global_model(
|
||||
api_format, &model_id,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let rows = filter_rows_for_models(rows, auth_snapshot, api_format);
|
||||
let rows = list_model_rows_for_client_format_and_global_model(
|
||||
state,
|
||||
api_format,
|
||||
&model_id,
|
||||
auth_snapshot,
|
||||
)
|
||||
.await?;
|
||||
let Some(row) = rows.first() else {
|
||||
return Some(build_models_not_found_response(&model_id, api_format));
|
||||
};
|
||||
|
||||
@@ -13,6 +13,23 @@ pub(crate) fn models_api_format(request_context: &GatewayPublicRequestContext) -
|
||||
.filter(|signature| matches!(*signature, "openai:chat" | "claude:chat" | "gemini:chat"))
|
||||
}
|
||||
|
||||
const MODELS_CROSS_FORMAT_QUERY_API_FORMATS: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
];
|
||||
|
||||
pub(super) fn models_query_api_formats(api_format: &str) -> &'static [&'static str] {
|
||||
match api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" | "claude:chat" | "gemini:chat" => MODELS_CROSS_FORMAT_QUERY_API_FORMATS,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn models_detail_id(request_path: &str) -> Option<String> {
|
||||
let raw = if let Some(value) = request_path.strip_prefix("/v1/models/") {
|
||||
value
|
||||
|
||||
@@ -231,9 +231,98 @@ pub(crate) fn provider_key_health_summary(
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_catalog_oauth_plan_type(value: &str, provider_type: &str) -> Option<String> {
|
||||
let mut normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
|
||||
normalized = normalized[provider_type.len()..]
|
||||
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let normalized = normalized.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_oauth_plan_type_from_source(
|
||||
source: &serde_json::Map<String, serde_json::Value>,
|
||||
provider_type: &str,
|
||||
fields: &[&str],
|
||||
) -> Option<String> {
|
||||
for field in fields {
|
||||
let Some(value) = source.get(*field).and_then(serde_json::Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(normalized) = normalize_catalog_oauth_plan_type(value, provider_type) {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn derive_catalog_oauth_plan_type(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<String> {
|
||||
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_type_key = provider_type.trim().to_ascii_lowercase();
|
||||
if let Some(upstream_metadata) = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
let provider_bucket = if provider_type_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
upstream_metadata
|
||||
.get(&provider_type_key)
|
||||
.and_then(serde_json::Value::as_object)
|
||||
};
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
if let Some(plan_type) = catalog_oauth_plan_type_from_source(
|
||||
source,
|
||||
provider_type,
|
||||
&[
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
"plan",
|
||||
],
|
||||
) {
|
||||
return Some(plan_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth_config.and_then(|source| {
|
||||
catalog_oauth_plan_type_from_source(
|
||||
source,
|
||||
provider_type,
|
||||
&["plan_type", "tier", "plan", "subscription_plan"],
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_provider_key_response(
|
||||
state: &AppState,
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
let request_count = u64::from(key.request_count.unwrap_or(0));
|
||||
@@ -257,16 +346,7 @@ pub(crate) fn build_admin_provider_key_response(
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let oauth_plan_type = auth_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("plan_type").and_then(serde_json::Value::as_str))
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
auth_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("tier").and_then(serde_json::Value::as_str))
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
});
|
||||
let oauth_plan_type = derive_catalog_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
||||
let (
|
||||
health_score,
|
||||
consecutive_failures,
|
||||
|
||||
Reference in New Issue
Block a user