mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Fix admin pool sorting and OAuth refresh
This commit is contained in:
@@ -2,14 +2,15 @@ pub(crate) use crate::handlers::admin::{
|
|||||||
admin_provider_ops_local_action_response, admin_provider_pool_config,
|
admin_provider_ops_local_action_response, admin_provider_pool_config,
|
||||||
build_internal_control_error_response, create_provider_oauth_catalog_key,
|
build_internal_control_error_response, create_provider_oauth_catalog_key,
|
||||||
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
|
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
|
||||||
maybe_build_local_admin_response, provider_oauth_runtime_endpoint_for_provider,
|
maybe_build_local_admin_response, provider_oauth_maintenance_endpoint_for_provider,
|
||||||
provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints,
|
provider_oauth_runtime_endpoint_for_provider, provider_type_supports_quota_refresh,
|
||||||
refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally,
|
reconcile_admin_fixed_provider_template_endpoints, refresh_antigravity_provider_quota_locally,
|
||||||
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally,
|
refresh_chatgpt_web_provider_quota_locally, refresh_codex_provider_quota_locally,
|
||||||
refresh_provider_oauth_account_state_after_update, update_existing_provider_oauth_catalog_key,
|
refresh_kiro_provider_quota_locally, refresh_provider_oauth_account_state_after_update,
|
||||||
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
|
update_existing_provider_oauth_catalog_key, AdminAppState,
|
||||||
AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
|
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,
|
||||||
AdminStatsTimeRange, AdminStatsUsageFilter,
|
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange,
|
||||||
|
AdminStatsUsageFilter,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::handlers::admin::{
|
use crate::handlers::admin::{
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ pub(crate) use self::provider::oauth::quota::codex::refresh_codex_provider_quota
|
|||||||
pub(crate) use self::provider::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
|
pub(crate) use self::provider::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
|
||||||
pub(crate) use self::provider::oauth::quota::shared::provider_type_supports_quota_refresh;
|
pub(crate) use self::provider::oauth::quota::shared::provider_type_supports_quota_refresh;
|
||||||
pub(crate) use self::provider::oauth::runtime::{
|
pub(crate) use self::provider::oauth::runtime::{
|
||||||
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
|
provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider,
|
||||||
|
refresh_provider_oauth_account_state_after_update,
|
||||||
};
|
};
|
||||||
pub(crate) use self::provider::ops::providers::actions::admin_provider_ops_local_action_response;
|
pub(crate) use self::provider::ops::providers::actions::admin_provider_ops_local_action_response;
|
||||||
pub(crate) use self::provider::pool::config::admin_provider_pool_config;
|
pub(crate) use self::provider::pool::config::admin_provider_pool_config;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use super::super::oauth::quota::shared::normalize_string_id_list;
|
|||||||
use super::super::oauth::quota::shared::{
|
use super::super::oauth::quota::shared::{
|
||||||
provider_type_supports_quota_refresh, unsupported_provider_quota_refresh_message,
|
provider_type_supports_quota_refresh, unsupported_provider_quota_refresh_message,
|
||||||
};
|
};
|
||||||
use super::super::oauth::runtime::provider_oauth_runtime_endpoint_for_provider;
|
use super::super::oauth::runtime::provider_oauth_maintenance_endpoint_for_provider;
|
||||||
use super::super::write::provider::reconcile_admin_fixed_provider_template_endpoints;
|
use super::super::write::provider::reconcile_admin_fixed_provider_template_endpoints;
|
||||||
|
|
||||||
fn unsupported_provider_quota_refresh_response(provider_type: &str) -> Response<Body> {
|
fn unsupported_provider_quota_refresh_response(provider_type: &str) -> Response<Body> {
|
||||||
@@ -115,7 +115,7 @@ pub(super) async fn maybe_handle(
|
|||||||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
|
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
|
||||||
.await?;
|
.await?;
|
||||||
let mut endpoint =
|
let mut endpoint =
|
||||||
provider_oauth_runtime_endpoint_for_provider(&normalized_provider_type, &endpoints);
|
provider_oauth_maintenance_endpoint_for_provider(&normalized_provider_type, &endpoints);
|
||||||
|
|
||||||
if endpoint.is_none() && is_fixed_provider {
|
if endpoint.is_none() && is_fixed_provider {
|
||||||
if !state.has_provider_catalog_data_writer() {
|
if !state.has_provider_catalog_data_writer() {
|
||||||
@@ -137,7 +137,7 @@ pub(super) async fn maybe_handle(
|
|||||||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
|
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
|
||||||
.await?;
|
.await?;
|
||||||
endpoint =
|
endpoint =
|
||||||
provider_oauth_runtime_endpoint_for_provider(&normalized_provider_type, &endpoints);
|
provider_oauth_maintenance_endpoint_for_provider(&normalized_provider_type, &endpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !provider_type_supports_quota_refresh(&normalized_provider_type) {
|
if !provider_type_supports_quota_refresh(&normalized_provider_type) {
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
|||||||
};
|
};
|
||||||
let endpoint_resolution =
|
let endpoint_resolution =
|
||||||
resolve_provider_oauth_runtime_endpoints(state, &provider, &provider_type).await?;
|
resolve_provider_oauth_runtime_endpoints(state, &provider, &provider_type).await?;
|
||||||
let endpoints = endpoint_resolution.endpoints;
|
|
||||||
let runtime_endpoint = endpoint_resolution.runtime_endpoint;
|
let runtime_endpoint = endpoint_resolution.runtime_endpoint;
|
||||||
let request_proxy = state
|
let request_proxy = state
|
||||||
.resolve_admin_provider_oauth_operation_proxy_snapshot(
|
.resolve_admin_provider_oauth_operation_proxy_snapshot(
|
||||||
@@ -223,10 +222,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
|||||||
let mut account_state_recheck_attempted = false;
|
let mut account_state_recheck_attempted = false;
|
||||||
let mut account_state_recheck_error = None::<String>;
|
let mut account_state_recheck_error = None::<String>;
|
||||||
if provider_type == "codex" {
|
if provider_type == "codex" {
|
||||||
if let Some(endpoint) = endpoints.into_iter().find(|endpoint| {
|
if let Some(endpoint) = runtime_endpoint {
|
||||||
endpoint.is_active
|
|
||||||
&& crate::ai_serving::is_openai_responses_format(&endpoint.api_format)
|
|
||||||
}) {
|
|
||||||
let refreshed_key = state
|
let refreshed_key = state
|
||||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -15,98 +15,95 @@ use aether_data_contracts::repository::provider_catalog::{
|
|||||||
pub(crate) fn provider_oauth_runtime_endpoint_for_provider(
|
pub(crate) fn provider_oauth_runtime_endpoint_for_provider(
|
||||||
provider_type: &str,
|
provider_type: &str,
|
||||||
endpoints: &[StoredProviderCatalogEndpoint],
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
|
) -> Option<StoredProviderCatalogEndpoint> {
|
||||||
|
select_provider_oauth_runtime_endpoint(provider_type, endpoints, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provider_oauth_maintenance_endpoint_for_provider(
|
||||||
|
provider_type: &str,
|
||||||
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
|
) -> Option<StoredProviderCatalogEndpoint> {
|
||||||
|
select_provider_oauth_runtime_endpoint(provider_type, endpoints, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matching_endpoint<F>(
|
||||||
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
|
include_inactive: bool,
|
||||||
|
predicate: F,
|
||||||
|
) -> Option<StoredProviderCatalogEndpoint>
|
||||||
|
where
|
||||||
|
F: Fn(&StoredProviderCatalogEndpoint) -> bool,
|
||||||
|
{
|
||||||
|
endpoints
|
||||||
|
.iter()
|
||||||
|
.find(|endpoint| endpoint.is_active && predicate(endpoint))
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| {
|
||||||
|
include_inactive.then(|| {
|
||||||
|
endpoints
|
||||||
|
.iter()
|
||||||
|
.find(|endpoint| !endpoint.is_active && predicate(endpoint))
|
||||||
|
.cloned()
|
||||||
|
})?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_provider_oauth_runtime_endpoint(
|
||||||
|
provider_type: &str,
|
||||||
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
|
include_inactive: bool,
|
||||||
) -> Option<StoredProviderCatalogEndpoint> {
|
) -> Option<StoredProviderCatalogEndpoint> {
|
||||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
match provider_type.as_str() {
|
match provider_type.as_str() {
|
||||||
"codex" => endpoints
|
"codex" => matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
.iter()
|
crate::ai_serving::is_openai_responses_format(&endpoint.api_format)
|
||||||
.find(|endpoint| {
|
}),
|
||||||
endpoint.is_active
|
"chatgpt_web" => matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
&& crate::ai_serving::is_openai_responses_format(&endpoint.api_format)
|
endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("openai:image")
|
||||||
|
}),
|
||||||
|
"antigravity" => matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
|
endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("gemini:generate_content")
|
||||||
|
}),
|
||||||
|
"kiro" => matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
|
endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
})
|
||||||
|
.or_else(|| matching_endpoint(endpoints, include_inactive, |_| true)),
|
||||||
|
"claude_code" => matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
|
endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
}),
|
||||||
|
"gemini_cli" => matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
|
endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("gemini:generate_content")
|
||||||
|
}),
|
||||||
|
"vertex_ai" => matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
|
endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("gemini:generate_content")
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||||
|
endpoint
|
||||||
|
.api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
})
|
})
|
||||||
.cloned(),
|
}),
|
||||||
"chatgpt_web" => endpoints
|
_ => matching_endpoint(endpoints, include_inactive, |_| true),
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("openai:image")
|
|
||||||
})
|
|
||||||
.cloned(),
|
|
||||||
"antigravity" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("gemini:generate_content")
|
|
||||||
})
|
|
||||||
.cloned(),
|
|
||||||
"kiro" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("claude:messages")
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.or_else(|| {
|
|
||||||
endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| endpoint.is_active)
|
|
||||||
.cloned()
|
|
||||||
}),
|
|
||||||
"claude_code" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("claude:messages")
|
|
||||||
})
|
|
||||||
.cloned(),
|
|
||||||
"gemini_cli" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("gemini:generate_content")
|
|
||||||
})
|
|
||||||
.cloned(),
|
|
||||||
"vertex_ai" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("gemini:generate_content")
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.or_else(|| {
|
|
||||||
endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("claude:messages")
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
}),
|
|
||||||
_ => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| endpoint.is_active)
|
|
||||||
.cloned(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +122,7 @@ pub(crate) async fn resolve_provider_oauth_runtime_endpoints(
|
|||||||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||||
.await?;
|
.await?;
|
||||||
let mut runtime_endpoint =
|
let mut runtime_endpoint =
|
||||||
provider_oauth_runtime_endpoint_for_provider(provider_type, &endpoints);
|
provider_oauth_maintenance_endpoint_for_provider(provider_type, &endpoints);
|
||||||
if runtime_endpoint.is_none()
|
if runtime_endpoint.is_none()
|
||||||
&& state
|
&& state
|
||||||
.fixed_provider_template(&provider.provider_type)
|
.fixed_provider_template(&provider.provider_type)
|
||||||
@@ -136,7 +133,8 @@ pub(crate) async fn resolve_provider_oauth_runtime_endpoints(
|
|||||||
endpoints = state
|
endpoints = state
|
||||||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||||
.await?;
|
.await?;
|
||||||
runtime_endpoint = provider_oauth_runtime_endpoint_for_provider(provider_type, &endpoints);
|
runtime_endpoint =
|
||||||
|
provider_oauth_maintenance_endpoint_for_provider(provider_type, &endpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ProviderOAuthRuntimeEndpoints {
|
Ok(ProviderOAuthRuntimeEndpoints {
|
||||||
|
|||||||
@@ -197,6 +197,35 @@ fn admin_pool_compare_optional_unix_secs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn admin_pool_compare_optional_score(
|
||||||
|
left: Option<f64>,
|
||||||
|
right: Option<f64>,
|
||||||
|
direction: AdminPoolKeySortDirection,
|
||||||
|
) -> Ordering {
|
||||||
|
match (left, right) {
|
||||||
|
(Some(left), Some(right)) => {
|
||||||
|
let ordering = left.partial_cmp(&right).unwrap_or(Ordering::Equal);
|
||||||
|
match direction {
|
||||||
|
AdminPoolKeySortDirection::Asc => ordering,
|
||||||
|
AdminPoolKeySortDirection::Desc => ordering.reverse(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(_), None) => Ordering::Less,
|
||||||
|
(None, Some(_)) => Ordering::Greater,
|
||||||
|
(None, None) => Ordering::Equal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_pool_score_for_key(
|
||||||
|
scores_by_key_id: &BTreeMap<String, StoredPoolMemberScore>,
|
||||||
|
key: &StoredProviderCatalogKey,
|
||||||
|
) -> Option<f64> {
|
||||||
|
scores_by_key_id
|
||||||
|
.get(&key.id)
|
||||||
|
.map(|score| score.score)
|
||||||
|
.filter(|score| score.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
fn admin_pool_sort_keys_for_request(keys: &mut [StoredProviderCatalogKey], sort: AdminPoolKeySort) {
|
fn admin_pool_sort_keys_for_request(keys: &mut [StoredProviderCatalogKey], sort: AdminPoolKeySort) {
|
||||||
match sort.field {
|
match sort.field {
|
||||||
AdminPoolKeySortField::Default => pool_selection::admin_pool_sort_keys(keys),
|
AdminPoolKeySortField::Default => pool_selection::admin_pool_sort_keys(keys),
|
||||||
@@ -222,9 +251,26 @@ fn admin_pool_sort_keys_for_request(keys: &mut [StoredProviderCatalogKey], sort:
|
|||||||
.then(left.id.cmp(&right.id))
|
.then(left.id.cmp(&right.id))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
AdminPoolKeySortField::Score => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn admin_pool_sort_keys_by_score(
|
||||||
|
keys: &mut [StoredProviderCatalogKey],
|
||||||
|
scores_by_key_id: &BTreeMap<String, StoredPoolMemberScore>,
|
||||||
|
direction: AdminPoolKeySortDirection,
|
||||||
|
) {
|
||||||
|
keys.sort_by(|left, right| {
|
||||||
|
admin_pool_compare_optional_score(
|
||||||
|
admin_pool_score_for_key(scores_by_key_id, left),
|
||||||
|
admin_pool_score_for_key(scores_by_key_id, right),
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
.then(left.name.cmp(&right.name))
|
||||||
|
.then(left.id.cmp(&right.id))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn admin_pool_repository_key_order(sort: AdminPoolKeySort) -> ProviderCatalogKeyListOrder {
|
fn admin_pool_repository_key_order(sort: AdminPoolKeySort) -> ProviderCatalogKeyListOrder {
|
||||||
match (sort.field, sort.direction) {
|
match (sort.field, sort.direction) {
|
||||||
(AdminPoolKeySortField::Default, _) => ProviderCatalogKeyListOrder::Name,
|
(AdminPoolKeySortField::Default, _) => ProviderCatalogKeyListOrder::Name,
|
||||||
@@ -240,6 +286,7 @@ fn admin_pool_repository_key_order(sort: AdminPoolKeySort) -> ProviderCatalogKey
|
|||||||
(AdminPoolKeySortField::LastUsedAt, AdminPoolKeySortDirection::Desc) => {
|
(AdminPoolKeySortField::LastUsedAt, AdminPoolKeySortDirection::Desc) => {
|
||||||
ProviderCatalogKeyListOrder::LastUsedAtDesc
|
ProviderCatalogKeyListOrder::LastUsedAtDesc
|
||||||
}
|
}
|
||||||
|
(AdminPoolKeySortField::Score, _) => ProviderCatalogKeyListOrder::Name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +363,9 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
|||||||
|
|
||||||
let pool_config = admin_provider_pool_config(&provider);
|
let pool_config = admin_provider_pool_config(&provider);
|
||||||
let page_offset = page.saturating_sub(1).saturating_mul(page_size);
|
let page_offset = page.saturating_sub(1).saturating_mul(page_size);
|
||||||
|
let sort_by_score = matches!(sort.field, AdminPoolKeySortField::Score);
|
||||||
|
|
||||||
let (keys, total) = if status == "cooldown" {
|
let (keys, total, preloaded_pool_scores_by_key_id) = if status == "cooldown" {
|
||||||
let cooldown_key_ids =
|
let cooldown_key_ids =
|
||||||
read_admin_provider_pool_cooldown_key_ids(state.runtime_state(), &provider.id).await;
|
read_admin_provider_pool_cooldown_key_ids(state.runtime_state(), &provider.id).await;
|
||||||
let mut keys = if cooldown_key_ids.is_empty() {
|
let mut keys = if cooldown_key_ids.is_empty() {
|
||||||
@@ -349,15 +397,25 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
admin_pool_sort_keys_for_request(&mut keys, sort);
|
let preloaded_pool_scores_by_key_id = if sort_by_score {
|
||||||
|
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
|
||||||
|
let scores = read_admin_pool_scores_by_key_id(state, &provider.id, &key_ids)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
admin_pool_sort_keys_by_score(&mut keys, &scores, sort.direction);
|
||||||
|
Some(scores)
|
||||||
|
} else {
|
||||||
|
admin_pool_sort_keys_for_request(&mut keys, sort);
|
||||||
|
None
|
||||||
|
};
|
||||||
let total = keys.len();
|
let total = keys.len();
|
||||||
let keys = keys
|
let keys = keys
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(page_offset)
|
.skip(page_offset)
|
||||||
.take(page_size)
|
.take(page_size)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
(keys, total)
|
(keys, total, preloaded_pool_scores_by_key_id)
|
||||||
} else if !quick_selectors.is_empty() {
|
} else if !quick_selectors.is_empty() || sort_by_score {
|
||||||
let mut keys = state
|
let mut keys = state
|
||||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||||
.await?
|
.await?
|
||||||
@@ -386,14 +444,24 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
admin_pool_sort_keys_for_request(&mut keys, sort);
|
let preloaded_pool_scores_by_key_id = if sort_by_score {
|
||||||
|
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
|
||||||
|
let scores = read_admin_pool_scores_by_key_id(state, &provider.id, &key_ids)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
admin_pool_sort_keys_by_score(&mut keys, &scores, sort.direction);
|
||||||
|
Some(scores)
|
||||||
|
} else {
|
||||||
|
admin_pool_sort_keys_for_request(&mut keys, sort);
|
||||||
|
None
|
||||||
|
};
|
||||||
let total = keys.len();
|
let total = keys.len();
|
||||||
let keys = keys
|
let keys = keys
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(page_offset)
|
.skip(page_offset)
|
||||||
.take(page_size)
|
.take(page_size)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
(keys, total)
|
(keys, total, preloaded_pool_scores_by_key_id)
|
||||||
} else {
|
} else {
|
||||||
let key_page = state
|
let key_page = state
|
||||||
.list_provider_catalog_key_page(&ProviderCatalogKeyListQuery {
|
.list_provider_catalog_key_page(&ProviderCatalogKeyListQuery {
|
||||||
@@ -409,13 +477,16 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
|||||||
order: admin_pool_repository_key_order(sort),
|
order: admin_pool_repository_key_order(sort),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
(key_page.items, key_page.total)
|
(key_page.items, key_page.total, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
|
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
|
||||||
let pool_scores_by_key_id = read_admin_pool_scores_by_key_id(state, &provider.id, &key_ids)
|
let pool_scores_by_key_id = match preloaded_pool_scores_by_key_id {
|
||||||
.await
|
Some(scores) => scores,
|
||||||
.unwrap_or_default();
|
None => read_admin_pool_scores_by_key_id(state, &provider.id, &key_ids)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
let endpoints = state
|
let endpoints = state
|
||||||
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub(crate) enum AdminPoolKeySortField {
|
|||||||
Default,
|
Default,
|
||||||
ImportedAt,
|
ImportedAt,
|
||||||
LastUsedAt,
|
LastUsedAt,
|
||||||
|
Score,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -121,8 +122,11 @@ pub(crate) fn parse_admin_pool_key_sort(query: Option<&str>) -> Result<AdminPool
|
|||||||
Some("name") => AdminPoolKeySortField::Default,
|
Some("name") => AdminPoolKeySortField::Default,
|
||||||
Some("imported_at") | Some("created_at") => AdminPoolKeySortField::ImportedAt,
|
Some("imported_at") | Some("created_at") => AdminPoolKeySortField::ImportedAt,
|
||||||
Some("last_used_at") | Some("last_used") => AdminPoolKeySortField::LastUsedAt,
|
Some("last_used_at") | Some("last_used") => AdminPoolKeySortField::LastUsedAt,
|
||||||
|
Some("score") | Some("pool_score") => AdminPoolKeySortField::Score,
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
return Err("sort_by must be one of: name, imported_at, last_used_at".to_string());
|
return Err(
|
||||||
|
"sort_by must be one of: name, imported_at, last_used_at, score".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let direction = match query_param_value(query, "sort_order")
|
let direction = match query_param_value(query, "sort_order")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use aether_data_contracts::repository::provider_catalog::{
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::admin_api::provider_oauth_maintenance_endpoint_for_provider;
|
||||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
@@ -75,9 +76,10 @@ pub(crate) async fn perform_oauth_token_refresh_once(
|
|||||||
}
|
}
|
||||||
summary.eligible = summary.eligible.saturating_add(1);
|
summary.eligible = summary.eligible.saturating_add(1);
|
||||||
|
|
||||||
let Some(endpoint) =
|
let Some(endpoint) = provider_oauth_maintenance_endpoint_for_provider(
|
||||||
oauth_runtime_endpoint_for_provider(&provider.provider_type, provider_endpoints)
|
&provider.provider_type,
|
||||||
else {
|
provider_endpoints,
|
||||||
|
) else {
|
||||||
summary.skipped = summary.skipped.saturating_add(1);
|
summary.skipped = summary.skipped.saturating_add(1);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -182,62 +184,6 @@ fn oauth_refresh_candidate(
|
|||||||
&& provider_key_is_oauth_managed(key, provider.provider_type.as_str())
|
&& provider_key_is_oauth_managed(key, provider.provider_type.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn oauth_runtime_endpoint_for_provider(
|
|
||||||
provider_type: &str,
|
|
||||||
endpoints: &[StoredProviderCatalogEndpoint],
|
|
||||||
) -> Option<StoredProviderCatalogEndpoint> {
|
|
||||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
|
||||||
match provider_type.as_str() {
|
|
||||||
"codex" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& crate::ai_serving::is_openai_responses_format(&endpoint.api_format)
|
|
||||||
})
|
|
||||||
.cloned(),
|
|
||||||
"chatgpt_web" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("openai:image")
|
|
||||||
})
|
|
||||||
.cloned(),
|
|
||||||
"antigravity" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("gemini:generate_content")
|
|
||||||
})
|
|
||||||
.cloned(),
|
|
||||||
"kiro" => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| {
|
|
||||||
endpoint.is_active
|
|
||||||
&& endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("claude:messages")
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.or_else(|| {
|
|
||||||
endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| endpoint.is_active)
|
|
||||||
.cloned()
|
|
||||||
}),
|
|
||||||
_ => endpoints
|
|
||||||
.iter()
|
|
||||||
.find(|endpoint| endpoint.is_active)
|
|
||||||
.cloned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn provider_key_credentials_changed(
|
async fn provider_key_credentials_changed(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
before: &StoredProviderCatalogKey,
|
before: &StoredProviderCatalogKey,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use serde_json::Value;
|
|||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::admin_api::{
|
use crate::admin_api::{
|
||||||
admin_provider_pool_config, provider_oauth_runtime_endpoint_for_provider,
|
admin_provider_pool_config, provider_oauth_maintenance_endpoint_for_provider,
|
||||||
provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints,
|
provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints,
|
||||||
refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally,
|
refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally,
|
||||||
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally, AdminAppState,
|
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally, AdminAppState,
|
||||||
@@ -421,7 +421,7 @@ fn endpoint_for_probe(
|
|||||||
provider_type: &str,
|
provider_type: &str,
|
||||||
endpoints: &[StoredProviderCatalogEndpoint],
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
) -> Option<StoredProviderCatalogEndpoint> {
|
) -> Option<StoredProviderCatalogEndpoint> {
|
||||||
provider_oauth_runtime_endpoint_for_provider(provider_type, endpoints)
|
provider_oauth_maintenance_endpoint_for_provider(provider_type, endpoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn endpoint_for_probe_with_reconcile(
|
async fn endpoint_for_probe_with_reconcile(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use aether_data::repository::oauth_providers::{
|
|||||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
|
ProviderCatalogReadRepository, ProviderCatalogWriteRepository, StoredProviderCatalogEndpoint,
|
||||||
};
|
};
|
||||||
use axum::body::{to_bytes, Body, Bytes};
|
use axum::body::{to_bytes, Body, Bytes};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
@@ -5267,6 +5267,39 @@ fn gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint_impl() {
|
async fn gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint_impl() {
|
||||||
|
run_gateway_manual_kiro_oauth_refresh_maintenance_endpoint_test(None, None, true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance() {
|
||||||
|
run_manual_kiro_oauth_refresh_test(
|
||||||
|
"gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance",
|
||||||
|
gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance_impl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gateway_manual_kiro_oauth_refresh_uses_disabled_fixed_endpoint_for_maintenance_impl() {
|
||||||
|
let mut endpoint = sample_endpoint(
|
||||||
|
"endpoint-kiro-disabled-maintenance",
|
||||||
|
"provider-kiro-oauth-refresh",
|
||||||
|
"claude:messages",
|
||||||
|
"https://q.{region}.amazonaws.com",
|
||||||
|
);
|
||||||
|
endpoint.is_active = false;
|
||||||
|
|
||||||
|
run_gateway_manual_kiro_oauth_refresh_maintenance_endpoint_test(
|
||||||
|
Some(endpoint),
|
||||||
|
Some("endpoint-kiro-disabled-maintenance"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_gateway_manual_kiro_oauth_refresh_maintenance_endpoint_test(
|
||||||
|
initial_endpoint: Option<StoredProviderCatalogEndpoint>,
|
||||||
|
expected_endpoint_id: Option<&str>,
|
||||||
|
expected_endpoint_active: bool,
|
||||||
|
) {
|
||||||
let refreshed_access_token = sample_kiro_device_access_token("kiro-refresh@example.com");
|
let refreshed_access_token = sample_kiro_device_access_token("kiro-refresh@example.com");
|
||||||
let expected_access_token = refreshed_access_token.clone();
|
let expected_access_token = refreshed_access_token.clone();
|
||||||
let refreshed_refresh_token = "s".repeat(120);
|
let refreshed_refresh_token = "s".repeat(120);
|
||||||
@@ -5362,7 +5395,7 @@ async fn gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint_imp
|
|||||||
|
|
||||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
vec![provider],
|
vec![provider],
|
||||||
Vec::new(),
|
initial_endpoint.into_iter().collect(),
|
||||||
vec![key],
|
vec![key],
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -5421,6 +5454,10 @@ async fn gateway_manual_kiro_oauth_refresh_reconciles_missing_fixed_endpoint_imp
|
|||||||
assert_eq!(endpoints.len(), 1);
|
assert_eq!(endpoints.len(), 1);
|
||||||
assert_eq!(endpoints[0].api_format, "claude:messages");
|
assert_eq!(endpoints[0].api_format, "claude:messages");
|
||||||
assert_eq!(endpoints[0].base_url, "https://q.{region}.amazonaws.com");
|
assert_eq!(endpoints[0].base_url, "https://q.{region}.amazonaws.com");
|
||||||
|
assert_eq!(endpoints[0].is_active, expected_endpoint_active);
|
||||||
|
if let Some(expected_endpoint_id) = expected_endpoint_id {
|
||||||
|
assert_eq!(endpoints[0].id, expected_endpoint_id);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*seen_endpoint_id.lock().expect("mutex should lock"),
|
*seen_endpoint_id.lock().expect("mutex should lock"),
|
||||||
Some(endpoints[0].id.clone())
|
Some(endpoints[0].id.clone())
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ async fn local_admin_pool_response(
|
|||||||
.expect("pool route should resolve locally")
|
.expect("pool route should resolve locally")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_pool_member_score(provider_id: &str, key_id: &str, score: f64) -> StoredPoolMemberScore {
|
||||||
|
let score_scope = provider_key_pool_score_scope();
|
||||||
|
let score_identity = PoolMemberIdentity::provider_api_key(provider_id, key_id);
|
||||||
|
StoredPoolMemberScore {
|
||||||
|
id: provider_key_pool_score_id(&score_identity, &score_scope),
|
||||||
|
pool_kind: score_identity.pool_kind.clone(),
|
||||||
|
pool_id: score_identity.pool_id.clone(),
|
||||||
|
member_kind: score_identity.member_kind.clone(),
|
||||||
|
member_id: score_identity.member_id.clone(),
|
||||||
|
capability: score_scope.capability.clone(),
|
||||||
|
scope_kind: score_scope.scope_kind.clone(),
|
||||||
|
scope_id: score_scope.scope_id.clone(),
|
||||||
|
score,
|
||||||
|
hard_state: PoolMemberHardState::Available,
|
||||||
|
score_version: 1,
|
||||||
|
score_reason: json!({ "weights": { "manual_priority": score } }),
|
||||||
|
last_ranked_at: Some(1_700_000_000),
|
||||||
|
last_scheduled_at: None,
|
||||||
|
last_success_at: None,
|
||||||
|
last_failure_at: None,
|
||||||
|
failure_count: 0,
|
||||||
|
last_probe_attempt_at: None,
|
||||||
|
last_probe_success_at: None,
|
||||||
|
last_probe_failure_at: None,
|
||||||
|
probe_failure_count: 0,
|
||||||
|
probe_status: PoolMemberProbeStatus::Ok,
|
||||||
|
updated_at: 1_700_000_050,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sample_provider_key_usage_row(
|
fn sample_provider_key_usage_row(
|
||||||
id: &str,
|
id: &str,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
@@ -928,11 +958,16 @@ async fn gateway_sorts_admin_pool_keys_by_imported_and_last_used_time() {
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![old_key, fresh_key, active_key],
|
vec![old_key, fresh_key, active_key],
|
||||||
));
|
));
|
||||||
|
let pool_score_repository = Arc::new(InMemoryPoolMemberScoreRepository::seed(vec![
|
||||||
|
sample_pool_member_score("provider-openai", "key-openai-fresh", 0.35),
|
||||||
|
sample_pool_member_score("provider-openai", "key-openai-active", 0.92),
|
||||||
|
]));
|
||||||
let state = AppState::new()
|
let state = AppState::new()
|
||||||
.expect("gateway should build")
|
.expect("gateway should build")
|
||||||
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
.with_data_state_for_tests(
|
||||||
provider_catalog_repository,
|
GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog_repository)
|
||||||
));
|
.with_pool_score_repository_for_tests(pool_score_repository),
|
||||||
|
);
|
||||||
|
|
||||||
let default_response = local_admin_pool_response(
|
let default_response = local_admin_pool_response(
|
||||||
&state,
|
&state,
|
||||||
@@ -999,6 +1034,30 @@ async fn gateway_sorts_admin_pool_keys_by_imported_and_last_used_time() {
|
|||||||
.map(|item| item["key_name"].as_str().unwrap_or_default())
|
.map(|item| item["key_name"].as_str().unwrap_or_default())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(last_used_names, vec!["active", "old", "fresh"]);
|
assert_eq!(last_used_names, vec!["active", "old", "fresh"]);
|
||||||
|
|
||||||
|
let score_response = local_admin_pool_response(
|
||||||
|
&state,
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/pool/provider-openai/keys?page=1&page_size=50&status=all&sort_by=score&sort_order=desc",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(score_response.status(), StatusCode::OK);
|
||||||
|
let score_payload: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(score_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("json body should parse");
|
||||||
|
let score_names = score_payload["keys"]
|
||||||
|
.as_array()
|
||||||
|
.expect("keys should be array")
|
||||||
|
.iter()
|
||||||
|
.map(|item| item["key_name"].as_str().unwrap_or_default())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(score_names, vec!["active", "fresh", "old"]);
|
||||||
|
assert_eq!(score_payload["keys"][0]["pool_score"]["score"], json!(0.92));
|
||||||
|
assert!(score_payload["keys"][2]["pool_score"].is_null());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export interface PoolKeysQuery {
|
|||||||
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
||||||
quick_selectors?: string[]
|
quick_selectors?: string[]
|
||||||
search_scope?: 'name' | 'full'
|
search_scope?: 'name' | 'full'
|
||||||
sort_by?: 'imported_at' | 'last_used_at'
|
sort_by?: 'imported_at' | 'last_used_at' | 'score'
|
||||||
sort_order?: 'asc' | 'desc'
|
sort_order?: 'asc' | 'desc'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,49 @@ describe('poolManagementState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports score sort in storage and query state', () => {
|
||||||
|
writePoolManagementViewState(
|
||||||
|
{
|
||||||
|
providerId: 'provider-g',
|
||||||
|
search: 'score search',
|
||||||
|
status: 'cooldown',
|
||||||
|
page: 3,
|
||||||
|
pageSize: 25,
|
||||||
|
sortBy: 'score',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
statsMode: 'current_cycle',
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(readPoolManagementViewState({}, storage)).toMatchObject({
|
||||||
|
providerId: 'provider-g',
|
||||||
|
search: 'score search',
|
||||||
|
status: 'cooldown',
|
||||||
|
page: 3,
|
||||||
|
pageSize: 25,
|
||||||
|
sortBy: 'score',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
statsMode: 'current_cycle',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildPoolManagementQueryPatch({
|
||||||
|
providerId: 'provider-g',
|
||||||
|
search: 'score search',
|
||||||
|
status: 'cooldown',
|
||||||
|
page: 3,
|
||||||
|
pageSize: 25,
|
||||||
|
sortBy: 'score',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
statsMode: 'current_cycle',
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
sortBy: 'score',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('omits defaults when building query patch', () => {
|
it('omits defaults when building query patch', () => {
|
||||||
expect(
|
expect(
|
||||||
buildPoolManagementQueryPatch({
|
buildPoolManagementQueryPatch({
|
||||||
@@ -131,13 +174,13 @@ describe('poolManagementState', () => {
|
|||||||
status: 'all',
|
status: 'all',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
sortBy: 'last_used_at',
|
sortBy: 'score',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'desc',
|
||||||
statsMode: 'account_total',
|
statsMode: 'account_total',
|
||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
sortBy: 'last_used_at',
|
sortBy: 'score',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'desc',
|
||||||
statsMode: 'account_total',
|
statsMode: 'account_total',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type PoolManagementStatus = 'all' | 'active' | 'cooldown' | 'inactive'
|
export type PoolManagementStatus = 'all' | 'active' | 'cooldown' | 'inactive'
|
||||||
export type PoolManagementSortBy = 'imported_at' | 'last_used_at'
|
export type PoolManagementSortBy = 'imported_at' | 'last_used_at' | 'score'
|
||||||
export type PoolManagementSortOrder = 'asc' | 'desc'
|
export type PoolManagementSortOrder = 'asc' | 'desc'
|
||||||
export type PoolManagementStatsMode = 'current_cycle' | 'account_total'
|
export type PoolManagementStatsMode = 'current_cycle' | 'account_total'
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ function normalizePositiveInteger(value: unknown, fallback: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSortBy(value: unknown): PoolManagementSortBy | null {
|
function normalizeSortBy(value: unknown): PoolManagementSortBy | null {
|
||||||
if (value === 'imported_at' || value === 'last_used_at') {
|
if (value === 'imported_at' || value === 'last_used_at' || value === 'score') {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return DEFAULT_POOL_MANAGEMENT_VIEW_STATE.sortBy
|
return DEFAULT_POOL_MANAGEMENT_VIEW_STATE.sortBy
|
||||||
|
|||||||
@@ -124,6 +124,28 @@
|
|||||||
<Users class="w-3.5 h-3.5" />
|
<Users class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="min-w-0 flex-1 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0"
|
||||||
|
title="编辑提供商"
|
||||||
|
@click="openProviderEditDialog"
|
||||||
|
>
|
||||||
|
<Edit class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0"
|
||||||
|
title="编辑端点"
|
||||||
|
@click="openEndpointEditDialog"
|
||||||
|
>
|
||||||
|
<Plug class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div class="min-w-0 flex-1 flex justify-center">
|
<div class="min-w-0 flex-1 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -251,6 +273,26 @@
|
|||||||
@select="setProviderProxy"
|
@select="setProviderProxy"
|
||||||
@clear="clearProviderProxy"
|
@clear="clearProviderProxy"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="selectedProviderId"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="编辑提供商"
|
||||||
|
@click="openProviderEditDialog"
|
||||||
|
>
|
||||||
|
<Edit class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="selectedProviderId"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="编辑端点"
|
||||||
|
@click="openEndpointEditDialog"
|
||||||
|
>
|
||||||
|
<Plug class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="selectedProviderId"
|
v-if="selectedProviderId"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -405,12 +447,19 @@
|
|||||||
>
|
>
|
||||||
最后使用
|
最后使用
|
||||||
</SortableTableHead>
|
</SortableTableHead>
|
||||||
<TableHead
|
<SortableTableHead
|
||||||
class="font-semibold text-center whitespace-nowrap"
|
class="font-semibold text-center whitespace-nowrap"
|
||||||
|
column-key="score"
|
||||||
|
:active-key="sortBy"
|
||||||
|
:direction="sortOrder"
|
||||||
|
default-direction="desc"
|
||||||
|
align="center"
|
||||||
:style="{ width: desktopColumnWidths.score }"
|
:style="{ width: desktopColumnWidths.score }"
|
||||||
|
title="按分数排序"
|
||||||
|
@sort="handleTableSort"
|
||||||
>
|
>
|
||||||
分数
|
分数
|
||||||
</TableHead>
|
</SortableTableHead>
|
||||||
<SortableTableHead
|
<SortableTableHead
|
||||||
class="font-semibold text-center whitespace-nowrap"
|
class="font-semibold text-center whitespace-nowrap"
|
||||||
column-key="status"
|
column-key="status"
|
||||||
@@ -1344,6 +1393,20 @@
|
|||||||
:current-claude-config="selectedProviderClaudeConfig"
|
:current-claude-config="selectedProviderClaudeConfig"
|
||||||
@saved="handleSchedulingSaved"
|
@saved="handleSchedulingSaved"
|
||||||
/>
|
/>
|
||||||
|
<ProviderFormDialog
|
||||||
|
v-model="providerEditDialogOpen"
|
||||||
|
:provider="providerToEdit"
|
||||||
|
@provider-updated="handleProviderEditSaved"
|
||||||
|
/>
|
||||||
|
<EndpointFormDialog
|
||||||
|
v-if="selectedProviderData"
|
||||||
|
v-model="endpointEditDialogOpen"
|
||||||
|
:provider="selectedProviderData"
|
||||||
|
:endpoints="providerEndpointsForEdit"
|
||||||
|
:provider-format-conversion-enabled="selectedProviderData.enable_format_conversion"
|
||||||
|
@endpoint-created="handleEndpointEditSaved"
|
||||||
|
@endpoint-updated="handleEndpointEditSaved"
|
||||||
|
/>
|
||||||
<PoolAccountBatchDialog
|
<PoolAccountBatchDialog
|
||||||
v-if="selectedProviderId"
|
v-if="selectedProviderId"
|
||||||
v-model="showAccountBatchDialog"
|
v-model="showAccountBatchDialog"
|
||||||
@@ -1403,6 +1466,8 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
Edit,
|
||||||
|
Plug,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -1459,11 +1524,12 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ClaudeCodeAdvancedConfig,
|
ClaudeCodeAdvancedConfig,
|
||||||
EndpointAPIKey,
|
EndpointAPIKey,
|
||||||
|
ProviderEndpoint,
|
||||||
PoolAdvancedConfig,
|
PoolAdvancedConfig,
|
||||||
ProviderWithEndpointsSummary,
|
ProviderWithEndpointsSummary,
|
||||||
} from '@/api/endpoints/types/provider'
|
} from '@/api/endpoints/types/provider'
|
||||||
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||||
import { getProvider, updateProvider } from '@/api/endpoints'
|
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
||||||
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
|
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
|
||||||
@@ -1473,6 +1539,8 @@ import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllow
|
|||||||
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
|
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
|
||||||
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
||||||
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
||||||
|
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||||
|
import ProviderFormDialog from '@/features/providers/components/ProviderFormDialog.vue'
|
||||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||||
import {
|
import {
|
||||||
buildPoolMobileTagItems,
|
buildPoolMobileTagItems,
|
||||||
@@ -1635,6 +1703,8 @@ async function loadOverview(options: { cacheTtlMs?: number } = {}) {
|
|||||||
selectedProviderId.value = null
|
selectedProviderId.value = null
|
||||||
selectedProviderData.value = null
|
selectedProviderData.value = null
|
||||||
keysLoadedOnce.value = false
|
keysLoadedOnce.value = false
|
||||||
|
endpointEditDialogOpen.value = false
|
||||||
|
providerEndpointsForEdit.value = []
|
||||||
showAccountBatchDialog.value = false
|
showAccountBatchDialog.value = false
|
||||||
closeProviderProxyPopovers()
|
closeProviderProxyPopovers()
|
||||||
resetKeyPage()
|
resetKeyPage()
|
||||||
@@ -1847,6 +1917,8 @@ async function selectProvider(
|
|||||||
hasHydratedInitialProviderSelection = true
|
hasHydratedInitialProviderSelection = true
|
||||||
selectedProviderId.value = id
|
selectedProviderId.value = id
|
||||||
selectedProviderData.value = null
|
selectedProviderData.value = null
|
||||||
|
endpointEditDialogOpen.value = false
|
||||||
|
providerEndpointsForEdit.value = []
|
||||||
editingKeyDetail.value = null
|
editingKeyDetail.value = null
|
||||||
showAccountBatchDialog.value = false
|
showAccountBatchDialog.value = false
|
||||||
keyPermissionsDialogOpen.value = false
|
keyPermissionsDialogOpen.value = false
|
||||||
@@ -2509,7 +2581,7 @@ function sortCurrentPageKeysByPriority() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTableSort(payload: { key: string, direction: PoolManagementSortOrder }) {
|
function handleTableSort(payload: { key: string, direction: PoolManagementSortOrder }) {
|
||||||
if (payload.key !== 'imported_at' && payload.key !== 'last_used_at') return
|
if (payload.key !== 'imported_at' && payload.key !== 'last_used_at' && payload.key !== 'score') return
|
||||||
sortBy.value = payload.key
|
sortBy.value = payload.key
|
||||||
sortOrder.value = payload.direction
|
sortOrder.value = payload.direction
|
||||||
}
|
}
|
||||||
@@ -2854,16 +2926,91 @@ async function toggleKeyActive(key: PoolKeyDetail) {
|
|||||||
const showImportDialog = ref(false)
|
const showImportDialog = ref(false)
|
||||||
const showSchedulingDialog = ref(false)
|
const showSchedulingDialog = ref(false)
|
||||||
const showAdvancedDialog = ref(false)
|
const showAdvancedDialog = ref(false)
|
||||||
|
const providerEditDialogOpen = ref(false)
|
||||||
|
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||||||
|
const endpointEditDialogOpen = ref(false)
|
||||||
|
const providerEndpointsForEdit = ref<ProviderEndpoint[]>([])
|
||||||
const showAccountBatchDialog = ref(false)
|
const showAccountBatchDialog = ref(false)
|
||||||
const providerProxyMobilePopoverOpen = ref(false)
|
const providerProxyMobilePopoverOpen = ref(false)
|
||||||
const providerProxyDesktopPopoverOpen = ref(false)
|
const providerProxyDesktopPopoverOpen = ref(false)
|
||||||
const savingProviderProxy = ref(false)
|
const savingProviderProxy = ref(false)
|
||||||
const togglingProviderStatus = ref(false)
|
const togglingProviderStatus = ref(false)
|
||||||
|
let endpointEditRequestId = 0
|
||||||
|
|
||||||
function openSchedulingDialog() {
|
function openSchedulingDialog() {
|
||||||
showSchedulingDialog.value = true
|
showSchedulingDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openProviderEditDialog(): Promise<void> {
|
||||||
|
const providerId = selectedProviderId.value
|
||||||
|
if (!providerId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latest = await getProvider(providerId)
|
||||||
|
if (selectedProviderId.value !== providerId) return
|
||||||
|
selectedProviderData.value = latest
|
||||||
|
providerToEdit.value = latest
|
||||||
|
} catch (err) {
|
||||||
|
if (selectedProviderId.value !== providerId) return
|
||||||
|
if (!selectedProviderData.value) {
|
||||||
|
showError(parseApiError(err, '刷新提供商状态失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
providerToEdit.value = selectedProviderData.value
|
||||||
|
}
|
||||||
|
|
||||||
|
providerEditDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise<void> {
|
||||||
|
if (selectedProviderId.value === updatedProvider.id) {
|
||||||
|
selectedProviderData.value = updatedProvider
|
||||||
|
providerToEdit.value = updatedProvider
|
||||||
|
}
|
||||||
|
providerEditDialogOpen.value = false
|
||||||
|
await loadOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEndpointEditDialog(): Promise<void> {
|
||||||
|
const providerId = selectedProviderId.value
|
||||||
|
if (!providerId) return
|
||||||
|
|
||||||
|
const requestId = ++endpointEditRequestId
|
||||||
|
try {
|
||||||
|
const [provider, endpoints] = await Promise.all([
|
||||||
|
getProvider(providerId),
|
||||||
|
getProviderEndpoints(providerId),
|
||||||
|
])
|
||||||
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||||
|
selectedProviderData.value = provider
|
||||||
|
providerEndpointsForEdit.value = endpoints
|
||||||
|
endpointEditDialogOpen.value = true
|
||||||
|
} catch (err) {
|
||||||
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||||
|
showError(parseApiError(err, '加载端点失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEndpointEditSaved(): Promise<void> {
|
||||||
|
const providerId = selectedProviderId.value
|
||||||
|
if (!providerId) return
|
||||||
|
|
||||||
|
const requestId = ++endpointEditRequestId
|
||||||
|
try {
|
||||||
|
const [provider, endpoints] = await Promise.all([
|
||||||
|
getProvider(providerId),
|
||||||
|
getProviderEndpoints(providerId),
|
||||||
|
])
|
||||||
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||||
|
selectedProviderData.value = provider
|
||||||
|
providerEndpointsForEdit.value = endpoints
|
||||||
|
await Promise.all([loadOverview(), loadKeys()])
|
||||||
|
} catch (err) {
|
||||||
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||||
|
showError(parseApiError(err, '刷新端点失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getProviderProxyNodeName(): string | null {
|
function getProviderProxyNodeName(): string | null {
|
||||||
const nodeId = selectedProviderData.value?.proxy?.node_id
|
const nodeId = selectedProviderData.value?.proxy?.node_id
|
||||||
if (!nodeId) return null
|
if (!nodeId) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user