mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
feat(pool): add bulk key configuration management
This commit is contained in:
@@ -288,6 +288,18 @@ pub(super) fn classify_admin_observability_family_route(
|
||||
"admin:pool",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PATCH
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/pool/")
|
||||
&& normalized_path_no_trailing.ends_with("/keys/batch-update")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"pool_manage",
|
||||
"batch_update_keys",
|
||||
"admin:pool",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/pool/")
|
||||
&& normalized_path_no_trailing.ends_with("/keys/resolve-selection")
|
||||
|
||||
@@ -82,6 +82,17 @@ fn classifies_admin_pool_provider_key_routes_as_admin_proxy_route() {
|
||||
Some("batch_action_keys")
|
||||
);
|
||||
|
||||
let batch_update_uri: Uri = "/api/admin/pool/provider-1/keys/batch-update"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let batch_update = classify_control_route(&http::Method::PATCH, &batch_update_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(batch_update.route_family.as_deref(), Some("pool_manage"));
|
||||
assert_eq!(
|
||||
batch_update.route_kind.as_deref(),
|
||||
Some("batch_update_keys")
|
||||
);
|
||||
|
||||
let resolve_selection_uri: Uri = "/api/admin/pool/provider-1/keys/resolve-selection"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
|
||||
@@ -512,6 +512,20 @@ impl GatewayDataState {
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Option<Vec<StoredProviderCatalogKey>>, DataLayerError> {
|
||||
let updated = match &self.provider_catalog_writer {
|
||||
Some(repository) => repository.update_keys(keys).await.map(Some),
|
||||
None => Ok(None),
|
||||
}?;
|
||||
if updated.as_ref().is_some_and(|keys| !keys.is_empty()) {
|
||||
self.clear_provider_catalog_cache();
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::handlers::admin::admin_provider_pool_config;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_update_key_id;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
|
||||
use crate::handlers::admin::provider::write::keys::admin_provider_key_update_requires_immediate_model_fetch;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::maintenance::ensure_provider_key_pool_scores_for_keys;
|
||||
use crate::provider_key_auth::provider_key_effective_api_formats;
|
||||
@@ -84,12 +85,8 @@ pub(super) async fn maybe_handle(
|
||||
let Some(updated) = state.update_provider_catalog_key(&updated_record).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let auto_fetch_filters_changed = existing_key.model_include_patterns
|
||||
!= updated.model_include_patterns
|
||||
|| existing_key.model_exclude_patterns != updated.model_exclude_patterns;
|
||||
// 自动获取开启后,调整过滤规则也要立即刷新 allowed_models。
|
||||
let should_overwrite_allowed_models_immediately = updated.auto_fetch_models
|
||||
&& (!existing_key.auto_fetch_models || auto_fetch_filters_changed);
|
||||
let should_overwrite_allowed_models_immediately =
|
||||
admin_provider_key_update_requires_immediate_model_fetch(&existing_key, &updated);
|
||||
let updated = if should_overwrite_allowed_models_immediately {
|
||||
let summary =
|
||||
perform_model_fetch_for_key(state.as_ref(), &provider.id, &updated.id).await?;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use super::{
|
||||
admin_pool_provider_id_from_path, build_admin_pool_error_response,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyBatchUpdateRequest;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::GatewayError;
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub(super) async fn build_admin_pool_batch_update_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_provider_catalog_data_reader() {
|
||||
return Ok(build_admin_pool_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||
));
|
||||
}
|
||||
if !state.has_provider_catalog_data_writer() {
|
||||
return Ok(build_admin_pool_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(provider_id) = admin_pool_provider_id_from_path(request_context.path()) else {
|
||||
return Ok(build_admin_pool_error_response(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
"Provider 不存在",
|
||||
));
|
||||
};
|
||||
let payload = match request_body.filter(|body| !body.is_empty()) {
|
||||
Some(body) => match serde_json::from_slice::<AdminProviderKeyBatchUpdateRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(build_admin_pool_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"请求体必须包含 key_ids 与 patch",
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Ok(build_admin_pool_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"请求体必须包含 key_ids 与 patch",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
state
|
||||
.build_admin_pool_batch_update_response(&provider_id, payload)
|
||||
.await
|
||||
}
|
||||
@@ -15,6 +15,8 @@ mod batch_import;
|
||||
mod batch_shared;
|
||||
#[path = "batch_routes/task_status.rs"]
|
||||
mod batch_task_status;
|
||||
#[path = "batch_routes/update.rs"]
|
||||
mod batch_update;
|
||||
pub(crate) mod payloads;
|
||||
#[path = "read_routes/keys.rs"]
|
||||
mod read_keys;
|
||||
@@ -152,6 +154,16 @@ pub(crate) async fn maybe_build_local_admin_pool_response(
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
Some("batch_update_keys") => {
|
||||
return Ok(Some(
|
||||
batch_update::build_admin_pool_batch_update_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
Some("batch_delete_task_status") => {
|
||||
return Ok(Some(
|
||||
batch_task_status::build_admin_pool_batch_delete_task_status_response(
|
||||
|
||||
@@ -213,6 +213,10 @@ pub(crate) fn is_admin_pool_route(request_context: &AdminRequestContext<'_>) ->
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/batch-action")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.method() == http::Method::PATCH
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/batch-update")
|
||||
&& path.matches('/').count() == 6)
|
||||
|| (request_context.method() == http::Method::POST
|
||||
&& path.starts_with("/api/admin/pool/")
|
||||
&& path.ends_with("/keys/resolve-selection")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::payload::{
|
||||
provider_query_extract_api_key_id, provider_query_extract_force_refresh,
|
||||
provider_query_extract_model, provider_query_extract_provider_id,
|
||||
provider_query_extract_request_id,
|
||||
provider_query_extract_api_key_id, provider_query_extract_api_key_ids,
|
||||
provider_query_extract_force_refresh, provider_query_extract_model,
|
||||
provider_query_extract_provider_id, provider_query_extract_request_id,
|
||||
};
|
||||
use super::response::{
|
||||
build_admin_provider_query_bad_request_response, build_admin_provider_query_not_found_response,
|
||||
@@ -104,6 +104,26 @@ struct ProviderQueryKeyFetchResult {
|
||||
has_success: bool,
|
||||
}
|
||||
|
||||
fn provider_query_select_model_keys(
|
||||
keys: Vec<StoredProviderCatalogKey>,
|
||||
selected_key_ids: Option<&BTreeSet<String>>,
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, ()> {
|
||||
if selected_key_ids.is_some_and(|selected| {
|
||||
selected
|
||||
.iter()
|
||||
.any(|key_id| !keys.iter().any(|key| key.id == *key_id))
|
||||
}) {
|
||||
return Err(());
|
||||
}
|
||||
Ok(match selected_key_ids {
|
||||
Some(selected) => keys
|
||||
.into_iter()
|
||||
.filter(|key| selected.contains(&key.id))
|
||||
.collect(),
|
||||
None => keys.into_iter().filter(|key| key.is_active).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_query_model_id(model: &Value) -> Option<&str> {
|
||||
model
|
||||
.get("id")
|
||||
@@ -622,21 +642,27 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let active_keys = keys
|
||||
.into_iter()
|
||||
.filter(|key| key.is_active)
|
||||
.collect::<Vec<_>>();
|
||||
if active_keys.is_empty() {
|
||||
let selected_key_ids = provider_query_extract_api_key_ids(payload);
|
||||
let query_keys = match provider_query_select_model_keys(keys, selected_key_ids.as_ref()) {
|
||||
Ok(keys) => keys,
|
||||
Err(()) => {
|
||||
return Ok(build_admin_provider_query_not_found_response(
|
||||
ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL,
|
||||
));
|
||||
}
|
||||
};
|
||||
if query_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 query_key_count = query_keys.len();
|
||||
|
||||
if provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("antigravity")
|
||||
if selected_key_ids.is_none()
|
||||
&& provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("antigravity")
|
||||
&& !force_refresh
|
||||
{
|
||||
if let Some(models) = provider_query_read_provider_cached_models(state, &provider.id).await
|
||||
@@ -649,8 +675,8 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
"error": serde_json::Value::Null,
|
||||
"warning": serde_json::Value::Null,
|
||||
"from_cache": true,
|
||||
"keys_total": active_key_count,
|
||||
"keys_cached": active_key_count,
|
||||
"keys_total": query_key_count,
|
||||
"keys_cached": query_key_count,
|
||||
"keys_fetched": 0,
|
||||
},
|
||||
"provider": provider_query_provider_payload(&provider),
|
||||
@@ -664,9 +690,9 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("antigravity")
|
||||
{
|
||||
provider_query_sort_antigravity_keys(state, &provider, &endpoints, active_keys).await?
|
||||
provider_query_sort_antigravity_keys(state, &provider, &endpoints, query_keys).await?
|
||||
} else {
|
||||
active_keys
|
||||
query_keys
|
||||
};
|
||||
|
||||
let mut all_models = Vec::new();
|
||||
@@ -709,10 +735,11 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
}
|
||||
|
||||
let models = aggregate_models_for_cache(&all_models);
|
||||
if provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("antigravity")
|
||||
if selected_key_ids.is_none()
|
||||
&& provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("antigravity")
|
||||
&& !models.is_empty()
|
||||
{
|
||||
provider_query_write_provider_cached_models(state, &provider.id, &models).await;
|
||||
@@ -742,7 +769,7 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
"error": error,
|
||||
"warning": warning,
|
||||
"from_cache": fetch_count == 0 && cache_hit_count > 0,
|
||||
"keys_total": active_key_count,
|
||||
"keys_total": query_key_count,
|
||||
"keys_cached": cache_hit_count,
|
||||
"keys_fetched": fetch_count,
|
||||
},
|
||||
@@ -770,6 +797,56 @@ mod tests {
|
||||
provider
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_model_keys_use_the_explicit_batch_scope() {
|
||||
let mut first = StoredProviderCatalogKey::new(
|
||||
"key-a".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"A".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("key should build");
|
||||
first.is_active = false;
|
||||
let second = StoredProviderCatalogKey::new(
|
||||
"key-b".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"B".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build");
|
||||
|
||||
let selected = BTreeSet::from(["key-a".to_string()]);
|
||||
let keys =
|
||||
provider_query_select_model_keys(vec![first.clone(), second.clone()], Some(&selected))
|
||||
.expect("explicit selection should resolve");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0].id, "key-a");
|
||||
|
||||
let active = provider_query_select_model_keys(vec![first, second], None)
|
||||
.expect("default selection should resolve");
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(active[0].id, "key-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_model_keys_reject_unknown_ids() {
|
||||
let key = StoredProviderCatalogKey::new(
|
||||
"key-a".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"A".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build");
|
||||
let selected = BTreeSet::from(["key-missing".to_string()]);
|
||||
assert!(provider_query_select_model_keys(vec![key], Some(&selected)).is_err());
|
||||
}
|
||||
|
||||
fn grok_key_with_quota(quota: Value) -> StoredProviderCatalogKey {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key-1".to_string(),
|
||||
|
||||
@@ -102,6 +102,12 @@ pub(crate) struct AdminProviderKeyUpdateRequest {
|
||||
|
||||
pub(crate) type AdminProviderKeyUpdatePatch = AdminTypedObjectPatch<AdminProviderKeyUpdateRequest>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AdminProviderKeyBatchUpdateRequest {
|
||||
pub(crate) key_ids: Vec<String>,
|
||||
pub(crate) patch: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AdminProviderKeyBatchDeleteRequest {
|
||||
pub(crate) ids: Vec<String>,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const BATCH_EDITABLE_KEY_FIELDS: &[&str] = &[
|
||||
"allow_auth_channel_mismatch_formats",
|
||||
"allowed_models",
|
||||
"api_formats",
|
||||
"auth_type_by_format",
|
||||
"auto_fetch_models",
|
||||
"cache_ttl_minutes",
|
||||
"capabilities",
|
||||
"concurrent_limit",
|
||||
"global_priority_by_format",
|
||||
"internal_priority",
|
||||
"is_active",
|
||||
"locked_models",
|
||||
"max_probe_interval_minutes",
|
||||
"model_exclude_patterns",
|
||||
"model_include_patterns",
|
||||
"note",
|
||||
"proxy",
|
||||
"rate_multipliers",
|
||||
"rpm_limit",
|
||||
];
|
||||
|
||||
pub(crate) fn parse_admin_provider_key_batch_update_patch(
|
||||
value: Value,
|
||||
) -> Result<Map<String, Value>, String> {
|
||||
let Value::Object(patch) = value else {
|
||||
return Err("patch 必须是 JSON 对象".to_string());
|
||||
};
|
||||
if patch.is_empty() {
|
||||
return Err("patch 至少包含一个可编辑字段".to_string());
|
||||
}
|
||||
|
||||
let allowed = BATCH_EDITABLE_KEY_FIELDS
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let unsupported = patch
|
||||
.keys()
|
||||
.filter(|field| !allowed.contains(field.as_str()))
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !unsupported.is_empty() {
|
||||
return Err(format!(
|
||||
"批量编辑不支持字段: {}",
|
||||
unsupported.into_iter().collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
AdminProviderKeyUpdatePatch::from_object(patch.clone())
|
||||
.map_err(|_| "patch 字段类型无效".to_string())?;
|
||||
Ok(patch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_admin_provider_key_batch_update_patch;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn accepts_shared_key_configuration_fields() {
|
||||
let patch = parse_admin_provider_key_batch_update_patch(json!({
|
||||
"api_formats": ["openai:responses"],
|
||||
"auto_fetch_models": true,
|
||||
"model_include_patterns": ["gpt-*"],
|
||||
"allowed_models": ["gpt-5.6-sol"],
|
||||
"rpm_limit": null
|
||||
}))
|
||||
.expect("batch patch should parse");
|
||||
|
||||
assert_eq!(patch.len(), 5);
|
||||
assert_eq!(patch["auto_fetch_models"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_identity_and_secret_fields() {
|
||||
let error = parse_admin_provider_key_batch_update_patch(json!({
|
||||
"name": "shared-name",
|
||||
"api_key": "sk-shared"
|
||||
}))
|
||||
.expect_err("identity fields must stay single-key only");
|
||||
|
||||
assert_eq!(error, "批量编辑不支持字段: api_key, name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_non_object_patch() {
|
||||
assert_eq!(
|
||||
parse_admin_provider_key_batch_update_patch(json!({}))
|
||||
.expect_err("empty patch should fail"),
|
||||
"patch 至少包含一个可编辑字段"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_admin_provider_key_batch_update_patch(json!([]))
|
||||
.expect_err("array patch should fail"),
|
||||
"patch 必须是 JSON 对象"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
pub(crate) use self::batch::parse_admin_provider_key_batch_update_patch;
|
||||
pub(crate) use self::create::build_admin_create_provider_key_record;
|
||||
pub(crate) use self::payload::build_admin_provider_keys_page_payload;
|
||||
pub(crate) use self::payload::build_admin_provider_keys_payload;
|
||||
pub(crate) use self::update::build_admin_update_provider_key_record;
|
||||
pub(crate) use self::update::{
|
||||
admin_provider_key_update_requires_immediate_model_fetch,
|
||||
build_admin_update_provider_key_record,
|
||||
build_admin_update_provider_key_record_with_existing_keys,
|
||||
};
|
||||
|
||||
mod batch;
|
||||
mod create;
|
||||
mod payload;
|
||||
mod update;
|
||||
|
||||
@@ -23,6 +23,27 @@ pub(crate) async fn build_admin_update_provider_key_record(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
existing: &StoredProviderCatalogKey,
|
||||
patch: AdminProviderKeyUpdatePatch,
|
||||
) -> Result<StoredProviderCatalogKey, String> {
|
||||
let existing_keys = state
|
||||
.as_ref()
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
build_admin_update_provider_key_record_with_existing_keys(
|
||||
state,
|
||||
provider,
|
||||
existing,
|
||||
&existing_keys,
|
||||
patch,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_update_provider_key_record_with_existing_keys(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
existing: &StoredProviderCatalogKey,
|
||||
existing_keys: &[StoredProviderCatalogKey],
|
||||
patch: AdminProviderKeyUpdatePatch,
|
||||
) -> Result<StoredProviderCatalogKey, String> {
|
||||
let state = state.as_ref();
|
||||
let mut updated = existing.clone();
|
||||
@@ -57,11 +78,6 @@ pub(crate) async fn build_admin_update_provider_key_record(
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned();
|
||||
|
||||
let existing_keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
match target_auth_type.as_str() {
|
||||
"api_key" | "bearer" => {
|
||||
if let Some(api_key) = api_key_value
|
||||
@@ -308,7 +324,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
|
||||
if let Some(auto_fetch_models) = payload.auto_fetch_models {
|
||||
updated.auto_fetch_models = auto_fetch_models;
|
||||
}
|
||||
if auto_fetch_disabled {
|
||||
if auto_fetch_disabled && !fields.contains("allowed_models") {
|
||||
updated.allowed_models = None;
|
||||
}
|
||||
if fields.contains("locked_models") {
|
||||
@@ -349,6 +365,17 @@ pub(crate) async fn build_admin_update_provider_key_record(
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) fn admin_provider_key_update_requires_immediate_model_fetch(
|
||||
existing: &StoredProviderCatalogKey,
|
||||
updated: &StoredProviderCatalogKey,
|
||||
) -> bool {
|
||||
let filters_changed = existing.model_include_patterns != updated.model_include_patterns
|
||||
|| existing.model_exclude_patterns != updated.model_exclude_patterns;
|
||||
let locked_models_changed = existing.locked_models != updated.locked_models;
|
||||
updated.auto_fetch_models
|
||||
&& (!existing.auto_fetch_models || filters_changed || locked_models_changed)
|
||||
}
|
||||
|
||||
fn raw_secret_auth_type(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
|
||||
@@ -177,6 +177,16 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.update_provider_catalog_key(key).await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_keys(
|
||||
&self,
|
||||
keys: &[aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey],
|
||||
) -> Result<
|
||||
Option<Vec<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.app.update_provider_catalog_keys(keys).await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_provider_catalog_key(
|
||||
&self,
|
||||
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||||
|
||||
@@ -13,6 +13,8 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
impl<'a> AdminAppState<'a> {
|
||||
pub(crate) async fn clear_admin_provider_pool_cooldown(&self, provider_id: &str, key_id: &str) {
|
||||
@@ -466,7 +468,7 @@ impl<'a> AdminAppState<'a> {
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let mut affected = 0usize;
|
||||
let mut updated_keys = Vec::with_capacity(keys.len());
|
||||
for mut key in keys {
|
||||
match plan.action {
|
||||
AdminPoolBatchActionKind::Enable => key.is_active = true,
|
||||
@@ -479,10 +481,13 @@ impl<'a> AdminAppState<'a> {
|
||||
}
|
||||
AdminPoolBatchActionKind::Delete => unreachable!(),
|
||||
}
|
||||
if self.update_provider_catalog_key(&key).await?.is_some() {
|
||||
affected = affected.saturating_add(1);
|
||||
}
|
||||
updated_keys.push(key);
|
||||
}
|
||||
let affected = self
|
||||
.update_provider_catalog_keys(&updated_keys)
|
||||
.await?
|
||||
.map(|keys| keys.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Json(
|
||||
admin_provider_pool_pure::build_admin_pool_batch_action_result_payload(
|
||||
@@ -492,4 +497,196 @@ impl<'a> AdminAppState<'a> {
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_pool_batch_update_response(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
payload: crate::handlers::admin::provider::shared::payloads::AdminProviderKeyBatchUpdateRequest,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
use crate::handlers::admin::provider::pool_admin::admin_provider_pool_config;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
|
||||
use crate::handlers::admin::provider::write::keys::{
|
||||
admin_provider_key_update_requires_immediate_model_fetch,
|
||||
build_admin_update_provider_key_record_with_existing_keys,
|
||||
parse_admin_provider_key_batch_update_patch,
|
||||
};
|
||||
use crate::maintenance::ensure_provider_key_pool_scores_for_keys;
|
||||
use crate::model_fetch::perform_model_fetch_for_keys;
|
||||
|
||||
let Some(provider) = self
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id.to_string()))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok((
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let requested_key_ids = payload
|
||||
.key_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if requested_key_ids.is_empty() {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "key_ids 不能为空" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let patch = match parse_admin_provider_key_batch_update_patch(payload.patch) {
|
||||
Ok(patch) => patch,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let provider_ids = vec![provider.id.clone()];
|
||||
let existing_keys = self
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await?;
|
||||
let keys_by_id = existing_keys
|
||||
.iter()
|
||||
.map(|key| (key.id.clone(), key))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let missing_key_ids = requested_key_ids
|
||||
.iter()
|
||||
.filter(|key_id| !keys_by_id.contains_key(*key_id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !missing_key_ids.is_empty() {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"detail": format!(
|
||||
"以下密钥不存在或不属于当前 Provider: {}",
|
||||
missing_key_ids.join(", ")
|
||||
)
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let mut staged_updates = Vec::with_capacity(requested_key_ids.len());
|
||||
for key_id in &requested_key_ids {
|
||||
let existing = keys_by_id
|
||||
.get(key_id)
|
||||
.expect("validated provider key should exist");
|
||||
let typed_patch = AdminProviderKeyUpdatePatch::from_object(patch.clone())
|
||||
.expect("validated batch patch should remain parseable");
|
||||
let updated = match build_admin_update_provider_key_record_with_existing_keys(
|
||||
self,
|
||||
&provider,
|
||||
existing,
|
||||
&existing_keys,
|
||||
typed_patch,
|
||||
) {
|
||||
Ok(updated) => updated,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"detail": format!("密钥 {} 配置无效: {detail}", existing.name)
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
staged_updates.push(((*existing).clone(), updated));
|
||||
}
|
||||
|
||||
let model_fetch_key_ids = staged_updates
|
||||
.iter()
|
||||
.filter(|(existing, updated)| {
|
||||
admin_provider_key_update_requires_immediate_model_fetch(existing, updated)
|
||||
})
|
||||
.map(|(_, updated)| updated.id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
let staged_records = staged_updates
|
||||
.into_iter()
|
||||
.map(|(_, updated)| updated)
|
||||
.collect::<Vec<_>>();
|
||||
let Some(updated_keys) = self.update_provider_catalog_keys(&staged_records).await? else {
|
||||
return Ok((
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": "Provider 密钥写入能力不可用" })),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let endpoints = self
|
||||
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
|
||||
.await?;
|
||||
if let Some(pool_config) = admin_provider_pool_config(&provider) {
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let score_ensure_budget =
|
||||
(pool_config.score_fallback_scan_limit as usize).clamp(1, 50_000);
|
||||
if let Err(err) = ensure_provider_key_pool_scores_for_keys(
|
||||
self.as_ref(),
|
||||
&provider,
|
||||
&pool_config,
|
||||
&endpoints,
|
||||
&updated_keys,
|
||||
now_unix_secs,
|
||||
score_ensure_budget,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
provider_id = %provider.id,
|
||||
updated_keys = updated_keys.len(),
|
||||
error = ?err,
|
||||
"gateway admin provider key batch update: failed to seed pool score rows"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let model_sync = if model_fetch_key_ids.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
let requested = model_fetch_key_ids.len();
|
||||
match perform_model_fetch_for_keys(self.as_ref(), &provider.id, &model_fetch_key_ids)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => json!({
|
||||
"requested": requested,
|
||||
"attempted": summary.attempted,
|
||||
"succeeded": summary.succeeded,
|
||||
"failed": summary.failed,
|
||||
"skipped": summary.skipped,
|
||||
}),
|
||||
Err(err) => json!({
|
||||
"requested": requested,
|
||||
"attempted": 0,
|
||||
"succeeded": 0,
|
||||
"failed": requested,
|
||||
"skipped": 0,
|
||||
"error": err.into_message(),
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
let affected = updated_keys.len();
|
||||
Ok(Json(json!({
|
||||
"affected": affected,
|
||||
"message": format!("已更新 {affected} 个密钥"),
|
||||
"model_sync": model_sync,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ mod tests;
|
||||
pub(crate) use aether_model_fetch::ModelFetchRunSummary;
|
||||
pub(crate) use runtime::state::ModelFetchRuntimeState;
|
||||
pub(crate) use runtime::{
|
||||
perform_model_fetch_for_key, perform_model_fetch_once, spawn_model_fetch_worker,
|
||||
perform_model_fetch_for_key, perform_model_fetch_for_keys, perform_model_fetch_once,
|
||||
spawn_model_fetch_worker,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -70,7 +70,16 @@ pub(crate) async fn perform_model_fetch_for_key(
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) -> Result<ModelFetchRunSummary, GatewayError> {
|
||||
perform_model_fetch_for_key_with_state(state, provider_id, key_id).await
|
||||
let key_ids = BTreeSet::from([key_id.to_string()]);
|
||||
perform_model_fetch_for_keys_with_state(state, provider_id, &key_ids).await
|
||||
}
|
||||
|
||||
pub(crate) async fn perform_model_fetch_for_keys(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
key_ids: &BTreeSet<String>,
|
||||
) -> Result<ModelFetchRunSummary, GatewayError> {
|
||||
perform_model_fetch_for_keys_with_state(state, provider_id, key_ids).await
|
||||
}
|
||||
|
||||
async fn perform_model_fetch_once_with_state<S>(
|
||||
@@ -83,22 +92,22 @@ where
|
||||
execute_fetch_targets(state, targets).await
|
||||
}
|
||||
|
||||
async fn perform_model_fetch_for_key_with_state<S>(
|
||||
async fn perform_model_fetch_for_keys_with_state<S>(
|
||||
state: &S,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
key_ids: &BTreeSet<String>,
|
||||
) -> Result<ModelFetchRunSummary, GatewayError>
|
||||
where
|
||||
S: ModelFetchRuntimeState + ?Sized,
|
||||
{
|
||||
let targets = collect_fetch_targets(state, Some(provider_id), Some(key_id)).await?;
|
||||
let targets = collect_fetch_targets(state, Some(provider_id), Some(key_ids)).await?;
|
||||
execute_fetch_targets(state, targets).await
|
||||
}
|
||||
|
||||
async fn collect_fetch_targets<S>(
|
||||
state: &S,
|
||||
provider_id_filter: Option<&str>,
|
||||
key_id_filter: Option<&str>,
|
||||
key_id_filter: Option<&BTreeSet<String>>,
|
||||
) -> Result<Vec<SelectedFetchTarget>, GatewayError>
|
||||
where
|
||||
S: ModelFetchRuntimeState + ?Sized,
|
||||
@@ -152,7 +161,7 @@ where
|
||||
.unwrap_or_default();
|
||||
let keys = keys_by_provider.remove(&provider.id).unwrap_or_default();
|
||||
for key in keys {
|
||||
if key_id_filter.is_some_and(|key_id| key.id != key_id) {
|
||||
if key_id_filter.is_some_and(|key_ids| !key_ids.contains(&key.id)) {
|
||||
continue;
|
||||
}
|
||||
if !key.is_active || !key.auto_fetch_models {
|
||||
|
||||
@@ -662,6 +662,21 @@ impl AppState {
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_keys(
|
||||
&self,
|
||||
keys: &[provider_catalog::StoredProviderCatalogKey],
|
||||
) -> Result<Option<Vec<provider_catalog::StoredProviderCatalogKey>>, GatewayError> {
|
||||
let updated = self
|
||||
.data
|
||||
.update_provider_catalog_keys(keys)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if updated.as_ref().is_some_and(|keys| !keys.is_empty()) {
|
||||
self.invalidate_provider_routing_caches();
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_key_runtime_state(
|
||||
&self,
|
||||
key: &provider_catalog::StoredProviderCatalogKey,
|
||||
|
||||
@@ -886,6 +886,7 @@ fn admin_provider_pool_admin_mod_stays_thin() {
|
||||
"#[path = \"batch_routes/import.rs\"]",
|
||||
"#[path = \"batch_routes/shared.rs\"]",
|
||||
"#[path = \"batch_routes/task_status.rs\"]",
|
||||
"#[path = \"batch_routes/update.rs\"]",
|
||||
"#[path = \"read_routes/keys.rs\"]",
|
||||
"#[path = \"read_routes/overview.rs\"]",
|
||||
"#[path = \"read_routes/presets.rs\"]",
|
||||
|
||||
@@ -3405,6 +3405,127 @@ async fn gateway_handles_admin_pool_batch_action_locally_with_trusted_admin_prin
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_batch_updates_shared_pool_key_configuration() {
|
||||
let provider = sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
let mut first_key = sample_key("key-openai-a", "provider-openai", "openai:chat", "sk-a");
|
||||
first_key.name = "alpha".to_string();
|
||||
first_key.auto_fetch_models = true;
|
||||
first_key.allowed_models = Some(json!(["legacy-model"]));
|
||||
let mut second_key = sample_key("key-openai-b", "provider-openai", "openai:chat", "sk-b");
|
||||
second_key.name = "beta".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![first_key, second_key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
||||
&provider_catalog_repository,
|
||||
)),
|
||||
);
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::PATCH,
|
||||
"/api/admin/pool/provider-openai/keys/batch-update",
|
||||
Some(json!({
|
||||
"key_ids": ["key-openai-b", "key-openai-a", "key-openai-a"],
|
||||
"patch": {
|
||||
"api_formats": ["openai:responses"],
|
||||
"internal_priority": 7,
|
||||
"rpm_limit": null,
|
||||
"auto_fetch_models": false,
|
||||
"allowed_models": ["gpt-5.6-sol", "gpt-5.6-luna"],
|
||||
"locked_models": [],
|
||||
"note": null
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
assert_eq!(payload["affected"], json!(2));
|
||||
assert_eq!(payload["model_sync"], serde_json::Value::Null);
|
||||
|
||||
let stored = provider_catalog_repository
|
||||
.list_keys_by_ids(&["key-openai-a".to_string(), "key-openai-b".to_string()])
|
||||
.await
|
||||
.expect("keys should load");
|
||||
assert_eq!(stored.len(), 2);
|
||||
for key in stored {
|
||||
assert_eq!(key.api_formats, Some(json!(["openai:responses"])));
|
||||
assert_eq!(key.internal_priority, 7);
|
||||
assert_eq!(key.rpm_limit, None);
|
||||
assert!(!key.auto_fetch_models);
|
||||
assert_eq!(
|
||||
key.allowed_models,
|
||||
Some(json!(["gpt-5.6-sol", "gpt-5.6-luna"]))
|
||||
);
|
||||
assert_eq!(key.locked_models, None);
|
||||
assert_eq!(key.note, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_pool_batch_update_before_writing_any_key() {
|
||||
let provider = sample_provider("provider-openai", "openai", 10);
|
||||
let mut first_key = sample_key("key-openai-a", "provider-openai", "openai:chat", "sk-a");
|
||||
first_key.internal_priority = 3;
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![first_key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
||||
&provider_catalog_repository,
|
||||
)),
|
||||
);
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::PATCH,
|
||||
"/api/admin/pool/provider-openai/keys/batch-update",
|
||||
Some(json!({
|
||||
"key_ids": ["key-openai-a", "key-missing"],
|
||||
"patch": { "internal_priority": 9 }
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let stored = provider_catalog_repository
|
||||
.list_keys_by_ids(&["key-openai-a".to_string()])
|
||||
.await
|
||||
.expect("key should load");
|
||||
assert_eq!(stored[0].internal_priority, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_pool_batch_delete_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -678,6 +678,11 @@ pub trait ProviderCatalogWriteRepository: Send + Sync {
|
||||
key: &StoredProviderCatalogKey,
|
||||
) -> Result<StoredProviderCatalogKey, crate::DataLayerError>;
|
||||
|
||||
async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, crate::DataLayerError>;
|
||||
|
||||
async fn update_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
|
||||
@@ -729,6 +729,28 @@ impl ProviderCatalogWriteRepository for InMemoryProviderCatalogReadRepository {
|
||||
Ok(stored.clone())
|
||||
}
|
||||
|
||||
async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
let mut index = self
|
||||
.index
|
||||
.write()
|
||||
.expect("provider catalog repository lock");
|
||||
for key in keys {
|
||||
if !index.keys.contains_key(&key.id) {
|
||||
return Err(DataLayerError::UnexpectedValue(format!(
|
||||
"provider catalog key {} not found",
|
||||
key.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
for key in keys {
|
||||
index.keys.insert(key.id.clone(), key.clone());
|
||||
}
|
||||
Ok(keys.to_vec())
|
||||
}
|
||||
|
||||
async fn update_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
|
||||
use sqlx::{
|
||||
mysql::{MySqlArguments, MySqlRow},
|
||||
query::Query,
|
||||
MySql, QueryBuilder, Row,
|
||||
};
|
||||
|
||||
use super::{
|
||||
InMemoryProviderCatalogReadRepository, ProviderCatalogKeyListQuery,
|
||||
@@ -667,87 +671,7 @@ WHERE id = ?
|
||||
) -> Result<StoredProviderCatalogKey, DataLayerError> {
|
||||
validate_key(key)?;
|
||||
let updated_at = key.updated_at_unix_secs.unwrap_or_else(current_unix_secs) as i64;
|
||||
let rows_affected = sqlx::query(key_update_sql())
|
||||
.bind(&key.provider_id)
|
||||
.bind(&key.name)
|
||||
.bind(&key.encrypted_api_key)
|
||||
.bind(&key.auth_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.capabilities,
|
||||
"provider_api_keys.capabilities",
|
||||
)?)
|
||||
.bind(key.is_active)
|
||||
.bind(optional_json_to_string(
|
||||
&key.api_formats,
|
||||
"provider_api_keys.api_formats",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.auth_type_by_format,
|
||||
"provider_api_keys.auth_type_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allow_auth_channel_mismatch_formats,
|
||||
"provider_api_keys.allow_auth_channel_mismatch_formats",
|
||||
)?)
|
||||
.bind(&key.encrypted_auth_config)
|
||||
.bind(&key.note)
|
||||
.bind(key.internal_priority)
|
||||
.bind(optional_json_to_string(
|
||||
&key.rate_multipliers,
|
||||
"provider_api_keys.rate_multipliers",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.global_priority_by_format,
|
||||
"provider_api_keys.global_priority_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allowed_models,
|
||||
"provider_api_keys.allowed_models",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.expires_at_unix_secs,
|
||||
"provider_api_keys.expires_at",
|
||||
)?)
|
||||
.bind(key.cache_ttl_minutes)
|
||||
.bind(key.max_probe_interval_minutes)
|
||||
.bind(optional_json_to_string(
|
||||
&key.proxy,
|
||||
"provider_api_keys.proxy",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.fingerprint,
|
||||
"provider_api_keys.fingerprint",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.rpm_limit))
|
||||
.bind(key.concurrent_limit)
|
||||
.bind(optional_i64_from_u32(key.learned_rpm_limit))
|
||||
.bind(optional_i64_from_u32(key.concurrent_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u32(key.rpm_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_429_at_unix_secs,
|
||||
"provider_api_keys.last_429_at",
|
||||
)?)
|
||||
.bind(&key.last_429_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.adjustment_history,
|
||||
"provider_api_keys.adjustment_history",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.utilization_samples,
|
||||
"provider_api_keys.utilization_samples",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_probe_increase_at_unix_secs,
|
||||
"provider_api_keys.last_probe_increase_at",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.last_rpm_peak))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_models_fetch_at_unix_secs,
|
||||
"provider_api_keys.last_models_fetch_at",
|
||||
)?)
|
||||
.bind(&key.last_models_fetch_error)
|
||||
.bind(updated_at)
|
||||
.bind(&key.id)
|
||||
let rows_affected = key_update_query(key, updated_at)?
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
@@ -762,6 +686,37 @@ WHERE id = ?
|
||||
self.reload_key(&key.id, "updated").await
|
||||
}
|
||||
|
||||
pub async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
if keys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
for key in keys {
|
||||
validate_key(key)?;
|
||||
}
|
||||
|
||||
let updated_at = current_unix_secs() as i64;
|
||||
let mut transaction = self.pool.begin().await.map_sql_err()?;
|
||||
for key in keys {
|
||||
let key_updated_at = key.updated_at_unix_secs.unwrap_or(updated_at as u64) as i64;
|
||||
let rows_affected = key_update_query(key, key_updated_at)?
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
if rows_affected == 0 {
|
||||
return Err(DataLayerError::UnexpectedValue(format!(
|
||||
"provider catalog key {} not found",
|
||||
key.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
transaction.commit().await.map_sql_err()?;
|
||||
Ok(keys.to_vec())
|
||||
}
|
||||
|
||||
pub async fn delete_key(&self, key_id: &str) -> Result<bool, DataLayerError> {
|
||||
validate_non_empty(key_id, "provider catalog key_id")?;
|
||||
let rows_affected = sqlx::query("DELETE FROM provider_api_keys WHERE id = ?")
|
||||
@@ -1092,6 +1047,13 @@ impl ProviderCatalogWriteRepository for MysqlProviderCatalogReadRepository {
|
||||
Self::update_key(self, key).await
|
||||
}
|
||||
|
||||
async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
Self::update_keys(self, keys).await
|
||||
}
|
||||
|
||||
async fn update_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
@@ -1312,11 +1274,115 @@ SET
|
||||
last_rpm_peak = ?,
|
||||
last_models_fetch_at = ?,
|
||||
last_models_fetch_error = ?,
|
||||
auto_fetch_models = ?,
|
||||
locked_models = ?,
|
||||
model_include_patterns = ?,
|
||||
model_exclude_patterns = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
"#
|
||||
}
|
||||
|
||||
fn key_update_query(
|
||||
key: &StoredProviderCatalogKey,
|
||||
updated_at: i64,
|
||||
) -> Result<Query<'_, MySql, MySqlArguments>, DataLayerError> {
|
||||
Ok(sqlx::query(key_update_sql())
|
||||
.bind(&key.provider_id)
|
||||
.bind(&key.name)
|
||||
.bind(&key.encrypted_api_key)
|
||||
.bind(&key.auth_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.capabilities,
|
||||
"provider_api_keys.capabilities",
|
||||
)?)
|
||||
.bind(key.is_active)
|
||||
.bind(optional_json_to_string(
|
||||
&key.api_formats,
|
||||
"provider_api_keys.api_formats",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.auth_type_by_format,
|
||||
"provider_api_keys.auth_type_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allow_auth_channel_mismatch_formats,
|
||||
"provider_api_keys.allow_auth_channel_mismatch_formats",
|
||||
)?)
|
||||
.bind(&key.encrypted_auth_config)
|
||||
.bind(&key.note)
|
||||
.bind(key.internal_priority)
|
||||
.bind(optional_json_to_string(
|
||||
&key.rate_multipliers,
|
||||
"provider_api_keys.rate_multipliers",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.global_priority_by_format,
|
||||
"provider_api_keys.global_priority_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allowed_models,
|
||||
"provider_api_keys.allowed_models",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.expires_at_unix_secs,
|
||||
"provider_api_keys.expires_at",
|
||||
)?)
|
||||
.bind(key.cache_ttl_minutes)
|
||||
.bind(key.max_probe_interval_minutes)
|
||||
.bind(optional_json_to_string(
|
||||
&key.proxy,
|
||||
"provider_api_keys.proxy",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.fingerprint,
|
||||
"provider_api_keys.fingerprint",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.rpm_limit))
|
||||
.bind(key.concurrent_limit)
|
||||
.bind(optional_i64_from_u32(key.learned_rpm_limit))
|
||||
.bind(optional_i64_from_u32(key.concurrent_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u32(key.rpm_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_429_at_unix_secs,
|
||||
"provider_api_keys.last_429_at",
|
||||
)?)
|
||||
.bind(&key.last_429_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.adjustment_history,
|
||||
"provider_api_keys.adjustment_history",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.utilization_samples,
|
||||
"provider_api_keys.utilization_samples",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_probe_increase_at_unix_secs,
|
||||
"provider_api_keys.last_probe_increase_at",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.last_rpm_peak))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_models_fetch_at_unix_secs,
|
||||
"provider_api_keys.last_models_fetch_at",
|
||||
)?)
|
||||
.bind(&key.last_models_fetch_error)
|
||||
.bind(key.auto_fetch_models)
|
||||
.bind(optional_json_to_string(
|
||||
&key.locked_models,
|
||||
"provider_api_keys.locked_models",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.model_include_patterns,
|
||||
"provider_api_keys.model_include_patterns",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.model_exclude_patterns,
|
||||
"provider_api_keys.model_exclude_patterns",
|
||||
)?)
|
||||
.bind(updated_at)
|
||||
.bind(&key.id))
|
||||
}
|
||||
|
||||
fn optional_json_from_string(
|
||||
value: Option<String>,
|
||||
field_name: &str,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use futures_util::TryStreamExt;
|
||||
use sqlx::{postgres::PgRow, PgPool, Postgres, QueryBuilder, Row};
|
||||
use sqlx::{
|
||||
postgres::{PgArguments, PgRow},
|
||||
query::Query,
|
||||
PgPool, Postgres, QueryBuilder, Row,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ProviderCatalogKeyListOrder, ProviderCatalogKeyListQuery, ProviderCatalogReadRepository,
|
||||
@@ -334,6 +338,136 @@ FROM provider_api_keys
|
||||
WHERE provider_id IN (
|
||||
"#;
|
||||
|
||||
const KEY_UPDATE_SQL: &str = r#"
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
provider_id = $2,
|
||||
api_formats = $3,
|
||||
auth_type_by_format = $40,
|
||||
allow_auth_channel_mismatch_formats = $41,
|
||||
auth_type = $4,
|
||||
api_key = $5,
|
||||
auth_config = $6,
|
||||
name = $7,
|
||||
note = $8,
|
||||
rate_multipliers = $9,
|
||||
internal_priority = $10,
|
||||
global_priority_by_format = $11,
|
||||
rpm_limit = $12,
|
||||
concurrent_limit = $13,
|
||||
learned_rpm_limit = $14,
|
||||
allowed_models = $15,
|
||||
capabilities = $16,
|
||||
cache_ttl_minutes = $17,
|
||||
max_probe_interval_minutes = $18,
|
||||
auto_fetch_models = $19,
|
||||
locked_models = $20,
|
||||
model_include_patterns = $21,
|
||||
model_exclude_patterns = $22,
|
||||
proxy = $23,
|
||||
fingerprint = $24,
|
||||
upstream_metadata = $25,
|
||||
expires_at = CASE
|
||||
WHEN $39::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($39::double precision)
|
||||
END,
|
||||
oauth_invalid_at = CASE
|
||||
WHEN $26::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($26::double precision)
|
||||
END,
|
||||
oauth_invalid_reason = $27,
|
||||
status_snapshot = $28,
|
||||
concurrent_429_count = COALESCE($29, 0),
|
||||
rpm_429_count = COALESCE($30, 0),
|
||||
last_429_at = CASE
|
||||
WHEN $31::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($31::double precision)
|
||||
END,
|
||||
last_429_type = $32,
|
||||
adjustment_history = $33,
|
||||
utilization_samples = $34,
|
||||
last_probe_increase_at = CASE
|
||||
WHEN $35::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($35::double precision)
|
||||
END,
|
||||
last_rpm_peak = $36,
|
||||
is_active = $37,
|
||||
updated_at = CASE
|
||||
WHEN $38::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($38::double precision)
|
||||
END,
|
||||
last_models_fetch_at = CASE
|
||||
WHEN $42::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($42::double precision)
|
||||
END,
|
||||
last_models_fetch_error = $43
|
||||
WHERE id = $1
|
||||
"#;
|
||||
|
||||
fn validate_key_for_update(key: &StoredProviderCatalogKey) -> Result<(), DataLayerError> {
|
||||
if key.id.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider catalog key.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if key.provider_id.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider catalog key.provider_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn key_update_query(key: &StoredProviderCatalogKey) -> Query<'_, Postgres, PgArguments> {
|
||||
sqlx::query(KEY_UPDATE_SQL)
|
||||
.bind(&key.id)
|
||||
.bind(&key.provider_id)
|
||||
.bind(&key.api_formats)
|
||||
.bind(&key.auth_type)
|
||||
.bind(&key.encrypted_api_key)
|
||||
.bind(&key.encrypted_auth_config)
|
||||
.bind(&key.name)
|
||||
.bind(&key.note)
|
||||
.bind(&key.rate_multipliers)
|
||||
.bind(key.internal_priority)
|
||||
.bind(&key.global_priority_by_format)
|
||||
.bind(key.rpm_limit.map(|value| value as i32))
|
||||
.bind(key.concurrent_limit)
|
||||
.bind(key.learned_rpm_limit.map(|value| value as i32))
|
||||
.bind(&key.allowed_models)
|
||||
.bind(&key.capabilities)
|
||||
.bind(key.cache_ttl_minutes)
|
||||
.bind(key.max_probe_interval_minutes)
|
||||
.bind(key.auto_fetch_models)
|
||||
.bind(&key.locked_models)
|
||||
.bind(&key.model_include_patterns)
|
||||
.bind(&key.model_exclude_patterns)
|
||||
.bind(&key.proxy)
|
||||
.bind(&key.fingerprint)
|
||||
.bind(&key.upstream_metadata)
|
||||
.bind(key.oauth_invalid_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.oauth_invalid_reason)
|
||||
.bind(&key.status_snapshot)
|
||||
.bind(key.concurrent_429_count.map(|value| value as i32))
|
||||
.bind(key.rpm_429_count.map(|value| value as i32))
|
||||
.bind(key.last_429_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.last_429_type)
|
||||
.bind(&key.adjustment_history)
|
||||
.bind(&key.utilization_samples)
|
||||
.bind(
|
||||
key.last_probe_increase_at_unix_secs
|
||||
.map(|value| value as f64),
|
||||
)
|
||||
.bind(key.last_rpm_peak.map(|value| value as i32))
|
||||
.bind(key.is_active)
|
||||
.bind(key.updated_at_unix_secs.map(|value| value as f64))
|
||||
.bind(key.expires_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.auth_type_by_format)
|
||||
.bind(&key.allow_auth_channel_mismatch_formats)
|
||||
.bind(key.last_models_fetch_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.last_models_fetch_error)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxProviderCatalogReadRepository {
|
||||
pool: PgPool,
|
||||
@@ -1652,134 +1786,12 @@ WHERE id = $1
|
||||
&self,
|
||||
key: &StoredProviderCatalogKey,
|
||||
) -> Result<StoredProviderCatalogKey, DataLayerError> {
|
||||
if key.id.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider catalog key.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if key.provider_id.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"provider catalog key.provider_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let rows_affected = sqlx::query(
|
||||
r#"
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
provider_id = $2,
|
||||
api_formats = $3,
|
||||
auth_type_by_format = $40,
|
||||
allow_auth_channel_mismatch_formats = $41,
|
||||
auth_type = $4,
|
||||
api_key = $5,
|
||||
auth_config = $6,
|
||||
name = $7,
|
||||
note = $8,
|
||||
rate_multipliers = $9,
|
||||
internal_priority = $10,
|
||||
global_priority_by_format = $11,
|
||||
rpm_limit = $12,
|
||||
concurrent_limit = $13,
|
||||
learned_rpm_limit = $14,
|
||||
allowed_models = $15,
|
||||
capabilities = $16,
|
||||
cache_ttl_minutes = $17,
|
||||
max_probe_interval_minutes = $18,
|
||||
auto_fetch_models = $19,
|
||||
locked_models = $20,
|
||||
model_include_patterns = $21,
|
||||
model_exclude_patterns = $22,
|
||||
proxy = $23,
|
||||
fingerprint = $24,
|
||||
upstream_metadata = $25,
|
||||
expires_at = CASE
|
||||
WHEN $39::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($39::double precision)
|
||||
END,
|
||||
oauth_invalid_at = CASE
|
||||
WHEN $26::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($26::double precision)
|
||||
END,
|
||||
oauth_invalid_reason = $27,
|
||||
status_snapshot = $28,
|
||||
concurrent_429_count = COALESCE($29, 0),
|
||||
rpm_429_count = COALESCE($30, 0),
|
||||
last_429_at = CASE
|
||||
WHEN $31::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($31::double precision)
|
||||
END,
|
||||
last_429_type = $32,
|
||||
adjustment_history = $33,
|
||||
utilization_samples = $34,
|
||||
last_probe_increase_at = CASE
|
||||
WHEN $35::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($35::double precision)
|
||||
END,
|
||||
last_rpm_peak = $36,
|
||||
is_active = $37,
|
||||
updated_at = CASE
|
||||
WHEN $38::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($38::double precision)
|
||||
END,
|
||||
last_models_fetch_at = CASE
|
||||
WHEN $42::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($42::double precision)
|
||||
END,
|
||||
last_models_fetch_error = $43
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&key.id)
|
||||
.bind(&key.provider_id)
|
||||
.bind(&key.api_formats)
|
||||
.bind(&key.auth_type)
|
||||
.bind(&key.encrypted_api_key)
|
||||
.bind(&key.encrypted_auth_config)
|
||||
.bind(&key.name)
|
||||
.bind(&key.note)
|
||||
.bind(&key.rate_multipliers)
|
||||
.bind(key.internal_priority)
|
||||
.bind(&key.global_priority_by_format)
|
||||
.bind(key.rpm_limit.map(|value| value as i32))
|
||||
.bind(key.concurrent_limit)
|
||||
.bind(key.learned_rpm_limit.map(|value| value as i32))
|
||||
.bind(&key.allowed_models)
|
||||
.bind(&key.capabilities)
|
||||
.bind(key.cache_ttl_minutes)
|
||||
.bind(key.max_probe_interval_minutes)
|
||||
.bind(key.auto_fetch_models)
|
||||
.bind(&key.locked_models)
|
||||
.bind(&key.model_include_patterns)
|
||||
.bind(&key.model_exclude_patterns)
|
||||
.bind(&key.proxy)
|
||||
.bind(&key.fingerprint)
|
||||
.bind(&key.upstream_metadata)
|
||||
.bind(key.oauth_invalid_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.oauth_invalid_reason)
|
||||
.bind(&key.status_snapshot)
|
||||
.bind(key.concurrent_429_count.map(|value| value as i32))
|
||||
.bind(key.rpm_429_count.map(|value| value as i32))
|
||||
.bind(key.last_429_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.last_429_type)
|
||||
.bind(&key.adjustment_history)
|
||||
.bind(&key.utilization_samples)
|
||||
.bind(
|
||||
key.last_probe_increase_at_unix_secs
|
||||
.map(|value| value as f64),
|
||||
)
|
||||
.bind(key.last_rpm_peak.map(|value| value as i32))
|
||||
.bind(key.is_active)
|
||||
.bind(key.updated_at_unix_secs.map(|value| value as f64))
|
||||
.bind(key.expires_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.auth_type_by_format)
|
||||
.bind(&key.allow_auth_channel_mismatch_formats)
|
||||
.bind(key.last_models_fetch_at_unix_secs.map(|value| value as f64))
|
||||
.bind(&key.last_models_fetch_error)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.rows_affected();
|
||||
validate_key_for_update(key)?;
|
||||
let rows_affected = key_update_query(key)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.rows_affected();
|
||||
|
||||
if rows_affected == 0 {
|
||||
return Err(DataLayerError::UnexpectedValue(format!(
|
||||
@@ -1800,6 +1812,35 @@ WHERE id = $1
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
if keys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
for key in keys {
|
||||
validate_key_for_update(key)?;
|
||||
}
|
||||
|
||||
let mut transaction = self.pool.begin().await.map_postgres_err()?;
|
||||
for key in keys {
|
||||
let rows_affected = key_update_query(key)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.rows_affected();
|
||||
if rows_affected == 0 {
|
||||
return Err(DataLayerError::UnexpectedValue(format!(
|
||||
"provider catalog key {} not found",
|
||||
key.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
transaction.commit().await.map_postgres_err()?;
|
||||
Ok(keys.to_vec())
|
||||
}
|
||||
|
||||
pub async fn delete_key(&self, key_id: &str) -> Result<bool, DataLayerError> {
|
||||
if key_id.trim().is_empty() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
@@ -2037,6 +2078,13 @@ impl ProviderCatalogWriteRepository for SqlxProviderCatalogReadRepository {
|
||||
Self::update_key(self, key).await
|
||||
}
|
||||
|
||||
async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
Self::update_keys(self, keys).await
|
||||
}
|
||||
|
||||
async fn update_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{sqlite::SqliteRow, QueryBuilder, Row, Sqlite};
|
||||
use sqlx::{
|
||||
query::Query,
|
||||
sqlite::{SqliteArguments, SqliteRow},
|
||||
QueryBuilder, Row, Sqlite,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ProviderCatalogKeyListOrder, ProviderCatalogKeyListQuery, ProviderCatalogReadRepository,
|
||||
@@ -1091,87 +1095,7 @@ WHERE id = ?
|
||||
) -> Result<StoredProviderCatalogKey, DataLayerError> {
|
||||
validate_key(key)?;
|
||||
let updated_at = key.updated_at_unix_secs.unwrap_or_else(current_unix_secs) as i64;
|
||||
let rows_affected = sqlx::query(key_update_sql())
|
||||
.bind(&key.provider_id)
|
||||
.bind(&key.name)
|
||||
.bind(&key.encrypted_api_key)
|
||||
.bind(&key.auth_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.capabilities,
|
||||
"provider_api_keys.capabilities",
|
||||
)?)
|
||||
.bind(key.is_active)
|
||||
.bind(optional_json_to_string(
|
||||
&key.api_formats,
|
||||
"provider_api_keys.api_formats",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.auth_type_by_format,
|
||||
"provider_api_keys.auth_type_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allow_auth_channel_mismatch_formats,
|
||||
"provider_api_keys.allow_auth_channel_mismatch_formats",
|
||||
)?)
|
||||
.bind(&key.encrypted_auth_config)
|
||||
.bind(&key.note)
|
||||
.bind(key.internal_priority)
|
||||
.bind(optional_json_to_string(
|
||||
&key.rate_multipliers,
|
||||
"provider_api_keys.rate_multipliers",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.global_priority_by_format,
|
||||
"provider_api_keys.global_priority_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allowed_models,
|
||||
"provider_api_keys.allowed_models",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.expires_at_unix_secs,
|
||||
"provider_api_keys.expires_at",
|
||||
)?)
|
||||
.bind(key.cache_ttl_minutes)
|
||||
.bind(key.max_probe_interval_minutes)
|
||||
.bind(optional_json_to_string(
|
||||
&key.proxy,
|
||||
"provider_api_keys.proxy",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.fingerprint,
|
||||
"provider_api_keys.fingerprint",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.rpm_limit))
|
||||
.bind(key.concurrent_limit)
|
||||
.bind(optional_i64_from_u32(key.learned_rpm_limit))
|
||||
.bind(optional_i64_from_u32(key.concurrent_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u32(key.rpm_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_429_at_unix_secs,
|
||||
"provider_api_keys.last_429_at",
|
||||
)?)
|
||||
.bind(&key.last_429_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.adjustment_history,
|
||||
"provider_api_keys.adjustment_history",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.utilization_samples,
|
||||
"provider_api_keys.utilization_samples",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_probe_increase_at_unix_secs,
|
||||
"provider_api_keys.last_probe_increase_at",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.last_rpm_peak))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_models_fetch_at_unix_secs,
|
||||
"provider_api_keys.last_models_fetch_at",
|
||||
)?)
|
||||
.bind(&key.last_models_fetch_error)
|
||||
.bind(updated_at)
|
||||
.bind(&key.id)
|
||||
let rows_affected = key_update_query(key, updated_at)?
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
@@ -1186,6 +1110,37 @@ WHERE id = ?
|
||||
self.reload_key(&key.id, "updated").await
|
||||
}
|
||||
|
||||
pub async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
if keys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
for key in keys {
|
||||
validate_key(key)?;
|
||||
}
|
||||
|
||||
let updated_at = current_unix_secs() as i64;
|
||||
let mut transaction = self.pool.begin().await.map_sql_err()?;
|
||||
for key in keys {
|
||||
let key_updated_at = key.updated_at_unix_secs.unwrap_or(updated_at as u64) as i64;
|
||||
let rows_affected = key_update_query(key, key_updated_at)?
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_sql_err()?
|
||||
.rows_affected();
|
||||
if rows_affected == 0 {
|
||||
return Err(DataLayerError::UnexpectedValue(format!(
|
||||
"provider catalog key {} not found",
|
||||
key.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
transaction.commit().await.map_sql_err()?;
|
||||
Ok(keys.to_vec())
|
||||
}
|
||||
|
||||
pub async fn delete_key(&self, key_id: &str) -> Result<bool, DataLayerError> {
|
||||
validate_non_empty(key_id, "provider catalog key_id")?;
|
||||
let rows_affected = sqlx::query("DELETE FROM provider_api_keys WHERE id = ?")
|
||||
@@ -1501,6 +1456,13 @@ impl ProviderCatalogWriteRepository for SqliteProviderCatalogReadRepository {
|
||||
Self::update_key(self, key).await
|
||||
}
|
||||
|
||||
async fn update_keys(
|
||||
&self,
|
||||
keys: &[StoredProviderCatalogKey],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
Self::update_keys(self, keys).await
|
||||
}
|
||||
|
||||
async fn update_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
@@ -1763,11 +1725,115 @@ SET
|
||||
last_rpm_peak = ?,
|
||||
last_models_fetch_at = ?,
|
||||
last_models_fetch_error = ?,
|
||||
auto_fetch_models = ?,
|
||||
locked_models = ?,
|
||||
model_include_patterns = ?,
|
||||
model_exclude_patterns = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
"#
|
||||
}
|
||||
|
||||
fn key_update_query(
|
||||
key: &StoredProviderCatalogKey,
|
||||
updated_at: i64,
|
||||
) -> Result<Query<'_, Sqlite, SqliteArguments<'_>>, DataLayerError> {
|
||||
Ok(sqlx::query(key_update_sql())
|
||||
.bind(&key.provider_id)
|
||||
.bind(&key.name)
|
||||
.bind(&key.encrypted_api_key)
|
||||
.bind(&key.auth_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.capabilities,
|
||||
"provider_api_keys.capabilities",
|
||||
)?)
|
||||
.bind(key.is_active)
|
||||
.bind(optional_json_to_string(
|
||||
&key.api_formats,
|
||||
"provider_api_keys.api_formats",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.auth_type_by_format,
|
||||
"provider_api_keys.auth_type_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allow_auth_channel_mismatch_formats,
|
||||
"provider_api_keys.allow_auth_channel_mismatch_formats",
|
||||
)?)
|
||||
.bind(&key.encrypted_auth_config)
|
||||
.bind(&key.note)
|
||||
.bind(key.internal_priority)
|
||||
.bind(optional_json_to_string(
|
||||
&key.rate_multipliers,
|
||||
"provider_api_keys.rate_multipliers",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.global_priority_by_format,
|
||||
"provider_api_keys.global_priority_by_format",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.allowed_models,
|
||||
"provider_api_keys.allowed_models",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.expires_at_unix_secs,
|
||||
"provider_api_keys.expires_at",
|
||||
)?)
|
||||
.bind(key.cache_ttl_minutes)
|
||||
.bind(key.max_probe_interval_minutes)
|
||||
.bind(optional_json_to_string(
|
||||
&key.proxy,
|
||||
"provider_api_keys.proxy",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.fingerprint,
|
||||
"provider_api_keys.fingerprint",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.rpm_limit))
|
||||
.bind(key.concurrent_limit)
|
||||
.bind(optional_i64_from_u32(key.learned_rpm_limit))
|
||||
.bind(optional_i64_from_u32(key.concurrent_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u32(key.rpm_429_count).unwrap_or(0))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_429_at_unix_secs,
|
||||
"provider_api_keys.last_429_at",
|
||||
)?)
|
||||
.bind(&key.last_429_type)
|
||||
.bind(optional_json_to_string(
|
||||
&key.adjustment_history,
|
||||
"provider_api_keys.adjustment_history",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.utilization_samples,
|
||||
"provider_api_keys.utilization_samples",
|
||||
)?)
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_probe_increase_at_unix_secs,
|
||||
"provider_api_keys.last_probe_increase_at",
|
||||
)?)
|
||||
.bind(optional_i64_from_u32(key.last_rpm_peak))
|
||||
.bind(optional_i64_from_u64(
|
||||
key.last_models_fetch_at_unix_secs,
|
||||
"provider_api_keys.last_models_fetch_at",
|
||||
)?)
|
||||
.bind(&key.last_models_fetch_error)
|
||||
.bind(key.auto_fetch_models)
|
||||
.bind(optional_json_to_string(
|
||||
&key.locked_models,
|
||||
"provider_api_keys.locked_models",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.model_include_patterns,
|
||||
"provider_api_keys.model_include_patterns",
|
||||
)?)
|
||||
.bind(optional_json_to_string(
|
||||
&key.model_exclude_patterns,
|
||||
"provider_api_keys.model_exclude_patterns",
|
||||
)?)
|
||||
.bind(updated_at)
|
||||
.bind(&key.id))
|
||||
}
|
||||
|
||||
fn optional_json_from_string(
|
||||
value: Option<String>,
|
||||
field_name: &str,
|
||||
@@ -2332,6 +2398,13 @@ mod tests {
|
||||
created_key.last_models_fetch_error.as_deref(),
|
||||
Some("stale models fetch error")
|
||||
);
|
||||
let mut second_key = key.clone();
|
||||
second_key.id = "key-write-2".to_string();
|
||||
second_key.name = "Secondary Key".to_string();
|
||||
let created_second_key = repository
|
||||
.create_key(&second_key)
|
||||
.await
|
||||
.expect("second key should create");
|
||||
|
||||
let mut updated_key = created_key.clone();
|
||||
updated_key.name = "Updated Key".to_string();
|
||||
@@ -2351,6 +2424,55 @@ mod tests {
|
||||
);
|
||||
assert_eq!(updated_key.last_models_fetch_error, None);
|
||||
|
||||
let mut batch_first = updated_key.clone();
|
||||
batch_first.auto_fetch_models = true;
|
||||
batch_first.allowed_models = Some(json!(["gpt-4.1", "gpt-4.1-mini"]));
|
||||
batch_first.locked_models = Some(json!(["gpt-4.1"]));
|
||||
batch_first.model_include_patterns = Some(json!(["gpt-*"]));
|
||||
batch_first.model_exclude_patterns = Some(json!(["*-preview"]));
|
||||
let mut batch_second = created_second_key;
|
||||
batch_second.auto_fetch_models = true;
|
||||
batch_second.allowed_models = batch_first.allowed_models.clone();
|
||||
batch_second.locked_models = batch_first.locked_models.clone();
|
||||
batch_second.model_include_patterns = batch_first.model_include_patterns.clone();
|
||||
batch_second.model_exclude_patterns = batch_first.model_exclude_patterns.clone();
|
||||
|
||||
let batch_updated = repository
|
||||
.update_keys(&[batch_first, batch_second])
|
||||
.await
|
||||
.expect("keys should update in one transaction");
|
||||
assert_eq!(batch_updated.len(), 2);
|
||||
assert!(batch_updated.iter().all(|key| key.auto_fetch_models));
|
||||
assert!(batch_updated
|
||||
.iter()
|
||||
.all(|key| key.locked_models == Some(json!(["gpt-4.1"]))));
|
||||
assert!(batch_updated
|
||||
.iter()
|
||||
.all(|key| key.model_include_patterns == Some(json!(["gpt-*"]))));
|
||||
assert!(batch_updated
|
||||
.iter()
|
||||
.all(|key| key.model_exclude_patterns == Some(json!(["*-preview"]))));
|
||||
|
||||
let mut valid_change = batch_updated
|
||||
.iter()
|
||||
.find(|key| key.id == "key-write-1")
|
||||
.expect("first key should be returned")
|
||||
.clone();
|
||||
valid_change.name = "Must Roll Back".to_string();
|
||||
let mut missing_change = valid_change.clone();
|
||||
missing_change.id = "missing-key".to_string();
|
||||
assert!(repository
|
||||
.update_keys(&[valid_change, missing_change])
|
||||
.await
|
||||
.is_err());
|
||||
let rolled_back = repository
|
||||
.list_keys_by_ids(&["key-write-1".to_string()])
|
||||
.await
|
||||
.expect("first key should reload")
|
||||
.pop()
|
||||
.expect("first key should exist");
|
||||
assert_eq!(rolled_back.name, "Updated Key");
|
||||
|
||||
assert!(repository
|
||||
.update_key_upstream_metadata(
|
||||
"key-write-1",
|
||||
@@ -2402,6 +2524,10 @@ mod tests {
|
||||
.delete_key("key-write-1")
|
||||
.await
|
||||
.expect("key should delete"));
|
||||
assert!(repository
|
||||
.delete_key("key-write-2")
|
||||
.await
|
||||
.expect("second key should delete"));
|
||||
assert!(repository
|
||||
.delete_endpoint("endpoint-write-1")
|
||||
.await
|
||||
|
||||
@@ -1073,6 +1073,14 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async queryProviderModelsForKeys(providerId: string, apiKeyIds: string[], forceRefresh = false): Promise<ProviderModelsQueryResponse> {
|
||||
const response = await apiClient.post<ProviderModelsQueryResponse>(
|
||||
'/api/admin/provider-query/models',
|
||||
{ provider_id: providerId, api_key_ids: apiKeyIds, force_refresh: forceRefresh }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 测试 SMTP 连接,支持传入未保存的配置
|
||||
async testSmtpConnection(config: Record<string, unknown> = {}): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||
|
||||
@@ -340,6 +340,48 @@ export interface PoolBatchAction {
|
||||
payload?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchUpdatePatch {
|
||||
api_formats?: string[]
|
||||
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
|
||||
allow_auth_channel_mismatch_formats?: string[] | null
|
||||
rate_multipliers?: Record<string, number> | null
|
||||
internal_priority?: number
|
||||
global_priority_by_format?: Record<string, number> | null
|
||||
rpm_limit?: number | null
|
||||
concurrent_limit?: number | null
|
||||
allowed_models?: AllowedModels
|
||||
capabilities?: Record<string, boolean> | null
|
||||
cache_ttl_minutes?: number
|
||||
max_probe_interval_minutes?: number
|
||||
is_active?: boolean
|
||||
note?: string | null
|
||||
auto_fetch_models?: boolean
|
||||
locked_models?: string[]
|
||||
model_include_patterns?: string[]
|
||||
model_exclude_patterns?: string[]
|
||||
proxy?: ProxyConfig | null
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchUpdateRequest {
|
||||
key_ids: string[]
|
||||
patch: PoolKeyBatchUpdatePatch
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchModelSyncResult {
|
||||
requested: number
|
||||
attempted: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
skipped: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchUpdateResponse {
|
||||
affected: number
|
||||
message: string
|
||||
model_sync: PoolKeyBatchModelSyncResult | null
|
||||
}
|
||||
|
||||
interface PoolReadOptions {
|
||||
cacheTtlMs?: number
|
||||
}
|
||||
@@ -442,6 +484,18 @@ export async function batchActionPoolKeys(
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function batchUpdatePoolKeys(
|
||||
providerId: string,
|
||||
body: PoolKeyBatchUpdateRequest,
|
||||
): Promise<PoolKeyBatchUpdateResponse> {
|
||||
const response = await client.patch<PoolKeyBatchUpdateResponse>(
|
||||
`/api/admin/pool/${providerId}/keys/batch-update`,
|
||||
body,
|
||||
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export interface BatchDeleteTaskStatus {
|
||||
task_id: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="账号批量操作"
|
||||
title="密钥批量管理"
|
||||
:description="dialogDescription"
|
||||
size="3xl"
|
||||
persistent
|
||||
@@ -316,6 +316,7 @@ type QuickSelectorValue =
|
||||
| 'enabled'
|
||||
|
||||
type BatchActionValue =
|
||||
| 'edit_config'
|
||||
| 'export'
|
||||
| 'delete'
|
||||
| 'refresh_oauth'
|
||||
@@ -354,6 +355,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
changed: []
|
||||
'edit-config': [keyIds: string[]]
|
||||
}>()
|
||||
|
||||
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
||||
@@ -370,6 +372,7 @@ const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }>
|
||||
]
|
||||
|
||||
const ACTION_OPTIONS: BatchActionOption[] = [
|
||||
{ value: 'edit_config', label: '编辑配置', hint: '统一修改支持 API、调度参数与模型权限。' },
|
||||
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
|
||||
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
|
||||
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
|
||||
@@ -726,6 +729,11 @@ async function confirmAndExecuteAction(action: BatchActionValue): Promise<void>
|
||||
}
|
||||
if (!canExecuteSpecifiedAction(action)) return
|
||||
|
||||
if (action === 'edit_config') {
|
||||
await openBatchEditor()
|
||||
return
|
||||
}
|
||||
|
||||
const actionOption = ACTION_OPTIONS.find((item) => item.value === action)
|
||||
const actionLabel = actionOption?.label || '执行动作'
|
||||
const scopeLabel = selectAllFiltered.value ? '筛选结果' : '已选账号'
|
||||
@@ -739,6 +747,31 @@ async function confirmAndExecuteAction(action: BatchActionValue): Promise<void>
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
async function openBatchEditor(): Promise<void> {
|
||||
if (executing.value || selectedCount.value === 0) return
|
||||
executing.value = true
|
||||
progressDone.value = 0
|
||||
progressTotal.value = 0
|
||||
progressLabel.value = selectAllFiltered.value ? '正在解析筛选结果...' : '正在准备批量编辑...'
|
||||
try {
|
||||
const selectedKeys = await resolveSelectedItems()
|
||||
const keyIds = selectedKeys.map(key => key.key_id)
|
||||
if (keyIds.length === 0) {
|
||||
warning('未找到可编辑账号,请刷新列表重试')
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
emit('edit-config', keyIds)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '准备批量编辑失败'))
|
||||
} finally {
|
||||
executing.value = false
|
||||
progressDone.value = 0
|
||||
progressTotal.value = 0
|
||||
progressLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const DELETE_POLL_INTERVAL_MS = 2000
|
||||
const DELETE_POLL_MAX_MS = 10 * 60 * 1000
|
||||
const DELETE_POLL_MAX_FAILURES = 3
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="批量编辑密钥"
|
||||
:description="dialogDescription"
|
||||
:icon="ListChecks"
|
||||
size="3xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<Tabs v-model="activeTab">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="configuration">
|
||||
密钥配置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
模型权限
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="configuration"
|
||||
class="max-h-[min(64vh,40rem)] overflow-y-auto pr-1"
|
||||
>
|
||||
<div class="divide-y divide-border/70">
|
||||
<section class="space-y-3 py-4 first:pt-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<Checkbox v-model="form.applyApiFormats" />
|
||||
<span>支持的 API</span>
|
||||
</label>
|
||||
<div
|
||||
class="grid gap-2 sm:grid-cols-2"
|
||||
:class="!form.applyApiFormats ? 'pointer-events-none opacity-45' : ''"
|
||||
>
|
||||
<label
|
||||
v-for="format in visibleApiFormats"
|
||||
:key="format"
|
||||
class="flex min-h-9 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-muted/40"
|
||||
:class="form.apiFormats.includes(format) ? 'border-primary/50 bg-primary/5' : 'border-border/70'"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="form.apiFormats.includes(format)"
|
||||
:disabled="!form.applyApiFormats"
|
||||
@update:checked="checked => toggleApiFormat(format, checked)"
|
||||
/>
|
||||
<span class="truncate">{{ formatApiFormat(format) }}</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="visibleApiFormats.length === 0"
|
||||
class="text-xs text-muted-foreground sm:col-span-2"
|
||||
>
|
||||
当前提供商没有可配置的 API 格式
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 py-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyActive"
|
||||
label="启用状态"
|
||||
>
|
||||
<Select
|
||||
v-model="activeValue"
|
||||
:disabled="!form.applyActive"
|
||||
>
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">
|
||||
启用
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
禁用
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyInternalPriority"
|
||||
label="优先级"
|
||||
>
|
||||
<Input
|
||||
v-model="form.internalPriority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-9"
|
||||
:disabled="!form.applyInternalPriority"
|
||||
/>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyRpmLimit"
|
||||
label="RPM 限制"
|
||||
>
|
||||
<Input
|
||||
v-model="form.rpmLimit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="自适应"
|
||||
class="h-9"
|
||||
:disabled="!form.applyRpmLimit"
|
||||
/>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyConcurrentLimit"
|
||||
label="并发请求上限"
|
||||
>
|
||||
<Input
|
||||
v-model="form.concurrentLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="不限制"
|
||||
class="h-9"
|
||||
:disabled="!form.applyConcurrentLimit"
|
||||
/>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyCacheTtl"
|
||||
label="缓存 TTL"
|
||||
>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="form.cacheTtlMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="60"
|
||||
class="h-9 pr-12"
|
||||
:disabled="!form.applyCacheTtl"
|
||||
/>
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">分钟</span>
|
||||
</div>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyProbeInterval"
|
||||
label="熔断探测"
|
||||
>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="form.maxProbeIntervalMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="32"
|
||||
class="h-9 pr-12"
|
||||
:disabled="!form.applyProbeInterval"
|
||||
/>
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">分钟</span>
|
||||
</div>
|
||||
</BatchFieldToggle>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 py-4">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<Checkbox v-model="form.applyNote" />
|
||||
<span>备注</span>
|
||||
</label>
|
||||
<Textarea
|
||||
v-model="form.note"
|
||||
rows="3"
|
||||
placeholder="留空可清除备注"
|
||||
:disabled="!form.applyNote"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="models"
|
||||
class="max-h-[min(64vh,40rem)] overflow-y-auto pr-1"
|
||||
>
|
||||
<div class="space-y-4 py-1">
|
||||
<div class="flex flex-col gap-3 border-b border-border/70 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<Checkbox v-model="form.applyModels" />
|
||||
<span>批量管理模型权限</span>
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.modelMode"
|
||||
:disabled="!form.applyModels"
|
||||
>
|
||||
<SelectTrigger class="h-9 w-full sm:w-48">
|
||||
<SelectValue placeholder="选择管理方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">
|
||||
手动权限
|
||||
</SelectItem>
|
||||
<SelectItem value="automatic">
|
||||
自动发现
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.modelMode"
|
||||
class="space-y-4"
|
||||
:class="!form.applyModels ? 'pointer-events-none opacity-45' : ''"
|
||||
>
|
||||
<div
|
||||
v-if="form.modelMode === 'manual'"
|
||||
class="flex items-center justify-between gap-4 border-b border-border/70 pb-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium">
|
||||
允许全部模型
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭后仅允许下方选中的模型
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.unrestrictedModels"
|
||||
:disabled="!form.applyModels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-3 border-b border-border/70 pb-4 sm:grid-cols-2"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">包含规则</Label>
|
||||
<Input
|
||||
v-model="form.includePatterns"
|
||||
placeholder="gpt-*, claude-*"
|
||||
class="h-9"
|
||||
:disabled="!form.applyModels"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">排除规则</Label>
|
||||
<Input
|
||||
v-model="form.excludePatterns"
|
||||
placeholder="*-preview, *-beta"
|
||||
class="h-9"
|
||||
:disabled="!form.applyModels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-3"
|
||||
:class="modelSelectionDisabled ? 'pointer-events-none opacity-45' : ''"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="modelSearch"
|
||||
placeholder="搜索或输入自定义模型"
|
||||
class="h-9 pl-8"
|
||||
:disabled="modelSelectionDisabled"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9 shrink-0"
|
||||
:disabled="fetchingUpstreamModels || modelSelectionDisabled"
|
||||
@click="fetchUpstreamModels(true)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-2 h-4 w-4"
|
||||
:class="fetchingUpstreamModels ? 'animate-spin' : ''"
|
||||
/>
|
||||
获取上游模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{{ form.modelMode === 'automatic' ? '已锁定' : '已允许' }} {{ form.selectedModels.length }} 个模型
|
||||
</span>
|
||||
<button
|
||||
v-if="filteredModels.length > 0"
|
||||
type="button"
|
||||
class="text-primary hover:underline"
|
||||
:disabled="modelSelectionDisabled"
|
||||
@click="toggleFilteredModels"
|
||||
>
|
||||
{{ areFilteredModelsSelected ? '取消当前结果' : '选择当前结果' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-md border border-border/70">
|
||||
<button
|
||||
v-if="canAddCustomModel"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 border-b border-dashed px-3 py-2 text-left text-sm hover:bg-muted/40"
|
||||
:disabled="modelSelectionDisabled"
|
||||
@click="addCustomModel"
|
||||
>
|
||||
<Plus class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ normalizedModelSearch }}</span>
|
||||
<span class="text-xs text-muted-foreground">添加</span>
|
||||
</button>
|
||||
<div class="max-h-72 overflow-y-auto">
|
||||
<label
|
||||
v-for="model in filteredModels"
|
||||
:key="model.id"
|
||||
class="flex cursor-pointer items-center gap-2 border-b border-border/60 px-3 py-2 last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="form.selectedModels.includes(model.id)"
|
||||
:disabled="modelSelectionDisabled"
|
||||
@update:checked="checked => toggleModel(model.id, checked)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-sm">{{ model.id }}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="h-5 shrink-0 px-1.5 text-[10px]"
|
||||
>
|
||||
{{ model.source }}
|
||||
</Badge>
|
||||
</label>
|
||||
<div
|
||||
v-if="loadingModels"
|
||||
class="flex items-center justify-center py-10 text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredModels.length === 0"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无匹配模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="py-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
选择模型权限管理方式后继续
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="closeDialog"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving"
|
||||
@click="saveChanges"
|
||||
>
|
||||
<Loader2
|
||||
v-if="saving"
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
{{ saving ? '保存中...' : `应用到 ${keyIds.length} 个密钥` }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { ListChecks, Loader2, Plus, RefreshCw, Search } from 'lucide-vue-next'
|
||||
import { getProviderModels } from '@/api/endpoints/models'
|
||||
import { batchUpdatePoolKeys } from '@/api/endpoints/pool'
|
||||
import { formatApiFormat, sortApiFormats, type UpstreamModel } from '@/api/endpoints/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useUpstreamModelsCache } from '@/features/providers/composables/useUpstreamModelsCache'
|
||||
import BatchFieldToggle from './PoolKeyBatchFieldToggle.vue'
|
||||
import {
|
||||
buildPoolKeyBatchUpdatePatch,
|
||||
type PoolKeyBatchEditState,
|
||||
} from '../utils/poolKeyBatchEdit'
|
||||
|
||||
interface ModelOption {
|
||||
id: string
|
||||
source: '提供商' | '上游' | '自定义'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerName?: string
|
||||
keyIds: string[]
|
||||
availableApiFormats: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, warning, error: showError } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const { fetchModelsForKeys } = useUpstreamModelsCache()
|
||||
|
||||
const activeTab = ref('configuration')
|
||||
const saving = ref(false)
|
||||
const loadingProviderModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const providerModelIds = ref<string[]>([])
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
const modelSearch = ref('')
|
||||
|
||||
function createInitialForm(): PoolKeyBatchEditState {
|
||||
return {
|
||||
applyApiFormats: false,
|
||||
apiFormats: [],
|
||||
applyActive: false,
|
||||
isActive: true,
|
||||
applyInternalPriority: false,
|
||||
internalPriority: '0',
|
||||
applyRpmLimit: false,
|
||||
rpmLimit: '',
|
||||
applyConcurrentLimit: false,
|
||||
concurrentLimit: '',
|
||||
applyCacheTtl: false,
|
||||
cacheTtlMinutes: '5',
|
||||
applyProbeInterval: false,
|
||||
maxProbeIntervalMinutes: '32',
|
||||
applyNote: false,
|
||||
note: '',
|
||||
applyModels: false,
|
||||
modelMode: '',
|
||||
unrestrictedModels: true,
|
||||
selectedModels: [],
|
||||
includePatterns: '',
|
||||
excludePatterns: '',
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive<PoolKeyBatchEditState>(createInitialForm())
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
const providerName = props.providerName?.trim()
|
||||
const prefix = providerName ? `${providerName} · ` : ''
|
||||
return `${prefix}已选 ${props.keyIds.length} 个密钥`
|
||||
})
|
||||
const visibleApiFormats = computed(() => sortApiFormats(props.availableApiFormats || []))
|
||||
const loadingModels = computed(() => loadingProviderModels.value || fetchingUpstreamModels.value)
|
||||
const normalizedModelSearch = computed(() => modelSearch.value.trim())
|
||||
const modelSelectionDisabled = computed(() => (
|
||||
!form.applyModels || (form.modelMode === 'manual' && form.unrestrictedModels)
|
||||
))
|
||||
const activeValue = computed({
|
||||
get: () => form.isActive ? 'enabled' : 'disabled',
|
||||
set: value => { form.isActive = value === 'enabled' },
|
||||
})
|
||||
|
||||
const allModels = computed<ModelOption[]>(() => {
|
||||
const byId = new Map<string, ModelOption>()
|
||||
for (const id of providerModelIds.value) {
|
||||
const normalized = id.trim()
|
||||
if (normalized) byId.set(normalized, { id: normalized, source: '提供商' })
|
||||
}
|
||||
for (const model of upstreamModels.value) {
|
||||
const normalized = model.id?.trim()
|
||||
if (normalized && !byId.has(normalized)) {
|
||||
byId.set(normalized, { id: normalized, source: '上游' })
|
||||
}
|
||||
}
|
||||
for (const id of form.selectedModels) {
|
||||
const normalized = id.trim()
|
||||
if (normalized && !byId.has(normalized)) {
|
||||
byId.set(normalized, { id: normalized, source: '自定义' })
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
const search = normalizedModelSearch.value.toLowerCase()
|
||||
if (!search) return allModels.value
|
||||
return allModels.value.filter(model => model.id.toLowerCase().includes(search))
|
||||
})
|
||||
const canAddCustomModel = computed(() => {
|
||||
const model = normalizedModelSearch.value
|
||||
return Boolean(model) && !allModels.value.some(item => item.id === model)
|
||||
})
|
||||
const areFilteredModelsSelected = computed(() => (
|
||||
filteredModels.value.length > 0
|
||||
&& filteredModels.value.every(model => form.selectedModels.includes(model.id))
|
||||
))
|
||||
|
||||
function resetDialog(): void {
|
||||
Object.assign(form, createInitialForm())
|
||||
activeTab.value = 'configuration'
|
||||
modelSearch.value = ''
|
||||
providerModelIds.value = []
|
||||
upstreamModels.value = []
|
||||
}
|
||||
|
||||
function toggleApiFormat(format: string, checked: boolean): void {
|
||||
const next = new Set(form.apiFormats)
|
||||
if (checked) next.add(format)
|
||||
else next.delete(format)
|
||||
form.apiFormats = [...next]
|
||||
}
|
||||
|
||||
function toggleModel(modelId: string, checked: boolean): void {
|
||||
const next = new Set(form.selectedModels)
|
||||
if (checked) next.add(modelId)
|
||||
else next.delete(modelId)
|
||||
form.selectedModels = [...next]
|
||||
}
|
||||
|
||||
function toggleFilteredModels(): void {
|
||||
const next = new Set(form.selectedModels)
|
||||
const select = !areFilteredModelsSelected.value
|
||||
for (const model of filteredModels.value) {
|
||||
if (select) next.add(model.id)
|
||||
else next.delete(model.id)
|
||||
}
|
||||
form.selectedModels = [...next]
|
||||
}
|
||||
|
||||
function addCustomModel(): void {
|
||||
const model = normalizedModelSearch.value
|
||||
if (!model) return
|
||||
toggleModel(model, true)
|
||||
modelSearch.value = ''
|
||||
}
|
||||
|
||||
async function loadProviderModels(): Promise<void> {
|
||||
if (!props.providerId) return
|
||||
loadingProviderModels.value = true
|
||||
try {
|
||||
const models = await getProviderModels(props.providerId, { limit: 1000 })
|
||||
providerModelIds.value = models
|
||||
.map(model => model.provider_model_name?.trim())
|
||||
.filter((model): model is string => Boolean(model))
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '加载提供商模型失败'))
|
||||
} finally {
|
||||
loadingProviderModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpstreamModels(forceRefresh = false): Promise<void> {
|
||||
if (!props.providerId || props.keyIds.length === 0) return
|
||||
fetchingUpstreamModels.value = true
|
||||
try {
|
||||
const result = await fetchModelsForKeys(props.providerId, props.keyIds, forceRefresh)
|
||||
if (result.error) {
|
||||
warning(result.error)
|
||||
return
|
||||
}
|
||||
upstreamModels.value = result.models
|
||||
if (result.warning) warning(result.warning)
|
||||
else success(`已获取 ${result.models.length} 个上游模型`)
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean): void {
|
||||
if (!value && !saving.value) closeDialog()
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
if (saving.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function saveChanges(): Promise<void> {
|
||||
if (saving.value) return
|
||||
if (props.keyIds.length === 0) {
|
||||
warning('请选择要编辑的密钥')
|
||||
return
|
||||
}
|
||||
const build = buildPoolKeyBatchUpdatePatch(form)
|
||||
if (!build.patch || build.error) {
|
||||
warning(build.error || '批量配置无效')
|
||||
if (form.applyModels && build.error?.includes('模型')) activeTab.value = 'models'
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: '应用批量配置',
|
||||
message: `将对 ${props.keyIds.length} 个密钥修改:${build.fieldLabels.join('、')}。是否继续?`,
|
||||
confirmText: '确认应用',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await batchUpdatePoolKeys(props.providerId, {
|
||||
key_ids: props.keyIds,
|
||||
patch: build.patch,
|
||||
})
|
||||
const modelSync = result.model_sync
|
||||
if (modelSync?.failed) {
|
||||
warning(`已更新 ${result.affected} 个密钥,${modelSync.failed} 个账号的模型同步失败`)
|
||||
} else if (modelSync && modelSync.attempted < modelSync.requested) {
|
||||
warning(`已更新 ${result.affected} 个密钥,部分账号未执行即时模型同步`)
|
||||
} else {
|
||||
success(result.message)
|
||||
}
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '批量更新密钥失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
open => {
|
||||
if (!open) return
|
||||
resetDialog()
|
||||
void loadProviderModels()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<label class="flex items-center gap-2 text-xs font-medium">
|
||||
<Checkbox
|
||||
:model-value="modelValue"
|
||||
@update:model-value="value => emit('update:modelValue', value)"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
<div :class="!modelValue ? 'pointer-events-none opacity-45' : ''">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Checkbox } from '@/components/ui'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
@@ -249,13 +249,13 @@ import {
|
||||
Activity,
|
||||
ChevronDown,
|
||||
Edit,
|
||||
ListChecks,
|
||||
Plug,
|
||||
Power,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Button,
|
||||
@@ -369,7 +369,7 @@ const mobileActions = computed(() => {
|
||||
{ key: 'import', title: legacyT('添加账号'), event: 'import', icon: Upload },
|
||||
{ key: 'providerProxy' },
|
||||
{ key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal },
|
||||
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
|
||||
{ key: 'accountBatch', title: legacyT('密钥批量管理'), event: 'accountBatch', icon: ListChecks },
|
||||
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit },
|
||||
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
|
||||
]
|
||||
@@ -394,7 +394,7 @@ const desktopPostProxyActions = computed<HeaderAction[]>(() => {
|
||||
}
|
||||
actions.push(
|
||||
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
|
||||
{ key: 'accountBatch', title: legacyT('账号'), event: 'accountBatch', icon: Users },
|
||||
{ key: 'accountBatch', title: legacyT('密钥批量管理'), event: 'accountBatch', icon: ListChecks },
|
||||
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
|
||||
)
|
||||
return actions
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('PoolManagementHeader', () => {
|
||||
refreshTitle: '刷新',
|
||||
onImport: () => events.push('import'),
|
||||
onScheduling: () => events.push('scheduling'),
|
||||
onAccountBatch: () => events.push('accountBatch'),
|
||||
onDemandMetrics: () => events.push('demandMetrics'),
|
||||
onRefresh: () => events.push('refresh'),
|
||||
})
|
||||
@@ -53,10 +54,11 @@ describe('PoolManagementHeader', () => {
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[title="添加账号"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="点击调整号池调度"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="密钥批量管理"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="查看自适应热池指标"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click()
|
||||
|
||||
expect(events).toEqual(['import', 'scheduling', 'demandMetrics', 'refresh'])
|
||||
expect(events).toEqual(['import', 'scheduling', 'accountBatch', 'demandMetrics', 'refresh'])
|
||||
expect(root.textContent).toContain('2 维度')
|
||||
|
||||
app.unmount()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildPoolKeyBatchUpdatePatch,
|
||||
parsePoolKeyModelPatterns,
|
||||
type PoolKeyBatchEditState,
|
||||
} from '../poolKeyBatchEdit'
|
||||
|
||||
function state(overrides: Partial<PoolKeyBatchEditState> = {}): PoolKeyBatchEditState {
|
||||
return {
|
||||
applyApiFormats: false,
|
||||
apiFormats: [],
|
||||
applyActive: false,
|
||||
isActive: true,
|
||||
applyInternalPriority: false,
|
||||
internalPriority: '0',
|
||||
applyRpmLimit: false,
|
||||
rpmLimit: '',
|
||||
applyConcurrentLimit: false,
|
||||
concurrentLimit: '',
|
||||
applyCacheTtl: false,
|
||||
cacheTtlMinutes: '5',
|
||||
applyProbeInterval: false,
|
||||
maxProbeIntervalMinutes: '32',
|
||||
applyNote: false,
|
||||
note: '',
|
||||
applyModels: false,
|
||||
modelMode: '',
|
||||
unrestrictedModels: true,
|
||||
selectedModels: [],
|
||||
includePatterns: '',
|
||||
excludePatterns: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildPoolKeyBatchUpdatePatch', () => {
|
||||
it('only emits fields explicitly enabled by the operator', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyApiFormats: true,
|
||||
apiFormats: ['openai:responses', 'openai:responses', ' openai:chat '],
|
||||
applyRpmLimit: true,
|
||||
rpmLimit: '',
|
||||
}))
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.patch).toEqual({
|
||||
api_formats: ['openai:responses', 'openai:chat'],
|
||||
rpm_limit: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a manual model policy and preserves explicit restrictions while disabling discovery', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyModels: true,
|
||||
modelMode: 'manual',
|
||||
unrestrictedModels: false,
|
||||
selectedModels: ['gpt-5.6-sol', 'gpt-5.6-sol', 'gpt-5.6-luna'],
|
||||
}))
|
||||
|
||||
expect(result.patch).toEqual({
|
||||
auto_fetch_models: false,
|
||||
allowed_models: ['gpt-5.6-sol', 'gpt-5.6-luna'],
|
||||
locked_models: [],
|
||||
model_include_patterns: [],
|
||||
model_exclude_patterns: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('builds automatic discovery filters and locked models', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyModels: true,
|
||||
modelMode: 'automatic',
|
||||
selectedModels: ['gpt-5.6-sol'],
|
||||
includePatterns: 'gpt-*,\nclaude-*',
|
||||
excludePatterns: '*-preview, *-beta',
|
||||
}))
|
||||
|
||||
expect(result.patch).toEqual({
|
||||
auto_fetch_models: true,
|
||||
locked_models: ['gpt-5.6-sol'],
|
||||
model_include_patterns: ['gpt-*', 'claude-*'],
|
||||
model_exclude_patterns: ['*-preview', '*-beta'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty fields and invalid ranges before the request is sent', () => {
|
||||
expect(buildPoolKeyBatchUpdatePatch(state()).error).toBe('请至少启用一个批量编辑字段')
|
||||
expect(buildPoolKeyBatchUpdatePatch(state({
|
||||
applyApiFormats: true,
|
||||
})).error).toBe('请至少选择一个支持的 API')
|
||||
expect(buildPoolKeyBatchUpdatePatch(state({
|
||||
applyCacheTtl: true,
|
||||
cacheTtlMinutes: '61',
|
||||
})).error).toBe('缓存 TTL 必须是 0-60 的整数')
|
||||
expect(buildPoolKeyBatchUpdatePatch(state({
|
||||
applyModels: true,
|
||||
modelMode: 'manual',
|
||||
unrestrictedModels: false,
|
||||
})).error).toBe('请至少选择一个允许的模型')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePoolKeyModelPatterns', () => {
|
||||
it('normalizes comma and line separated patterns', () => {
|
||||
expect(parsePoolKeyModelPatterns(' gpt-* , claude-*\ngpt-* ')).toEqual([
|
||||
'gpt-*',
|
||||
'claude-*',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { PoolKeyBatchUpdatePatch } from '@/api/endpoints/pool'
|
||||
|
||||
export type PoolKeyBatchModelMode = '' | 'manual' | 'automatic'
|
||||
|
||||
export interface PoolKeyBatchEditState {
|
||||
applyApiFormats: boolean
|
||||
apiFormats: string[]
|
||||
applyActive: boolean
|
||||
isActive: boolean
|
||||
applyInternalPriority: boolean
|
||||
internalPriority: string
|
||||
applyRpmLimit: boolean
|
||||
rpmLimit: string
|
||||
applyConcurrentLimit: boolean
|
||||
concurrentLimit: string
|
||||
applyCacheTtl: boolean
|
||||
cacheTtlMinutes: string
|
||||
applyProbeInterval: boolean
|
||||
maxProbeIntervalMinutes: string
|
||||
applyNote: boolean
|
||||
note: string
|
||||
applyModels: boolean
|
||||
modelMode: PoolKeyBatchModelMode
|
||||
unrestrictedModels: boolean
|
||||
selectedModels: string[]
|
||||
includePatterns: string
|
||||
excludePatterns: string
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchPatchBuildResult {
|
||||
patch: PoolKeyBatchUpdatePatch | null
|
||||
fieldLabels: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function uniqueTrimmed(values: string[]): string[] {
|
||||
return [...new Set(values.map(value => value.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
export function parsePoolKeyModelPatterns(value: string): string[] {
|
||||
return uniqueTrimmed(value.split(/[,\n]/))
|
||||
}
|
||||
|
||||
function parseIntegerField(
|
||||
value: string,
|
||||
label: string,
|
||||
min: number,
|
||||
max?: number,
|
||||
nullable = false,
|
||||
): { value?: number | null; error?: string } {
|
||||
const normalized = value.trim()
|
||||
if (!normalized) {
|
||||
return nullable ? { value: null } : { error: `${label} 不能为空` }
|
||||
}
|
||||
const parsed = Number(normalized)
|
||||
if (!Number.isInteger(parsed) || parsed < min || (max !== undefined && parsed > max)) {
|
||||
const range = max === undefined ? `不小于 ${min}` : `${min}-${max}`
|
||||
return { error: `${label} 必须是 ${range} 的整数` }
|
||||
}
|
||||
return { value: parsed }
|
||||
}
|
||||
|
||||
export function buildPoolKeyBatchUpdatePatch(
|
||||
state: PoolKeyBatchEditState,
|
||||
): PoolKeyBatchPatchBuildResult {
|
||||
const patch: PoolKeyBatchUpdatePatch = {}
|
||||
const fieldLabels: string[] = []
|
||||
|
||||
if (state.applyApiFormats) {
|
||||
const apiFormats = uniqueTrimmed(state.apiFormats)
|
||||
if (apiFormats.length === 0) {
|
||||
return { patch: null, fieldLabels, error: '请至少选择一个支持的 API' }
|
||||
}
|
||||
patch.api_formats = apiFormats
|
||||
fieldLabels.push('支持 API')
|
||||
}
|
||||
|
||||
if (state.applyActive) {
|
||||
patch.is_active = state.isActive
|
||||
fieldLabels.push('启用状态')
|
||||
}
|
||||
|
||||
if (state.applyInternalPriority) {
|
||||
const parsed = parseIntegerField(state.internalPriority, '优先级', 0)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.internal_priority = parsed.value as number
|
||||
fieldLabels.push('优先级')
|
||||
}
|
||||
|
||||
if (state.applyRpmLimit) {
|
||||
const parsed = parseIntegerField(state.rpmLimit, 'RPM 限制', 1, 10000, true)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.rpm_limit = parsed.value
|
||||
fieldLabels.push('RPM 限制')
|
||||
}
|
||||
|
||||
if (state.applyConcurrentLimit) {
|
||||
const parsed = parseIntegerField(state.concurrentLimit, '并发请求上限', 0, undefined, true)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.concurrent_limit = parsed.value
|
||||
fieldLabels.push('并发请求上限')
|
||||
}
|
||||
|
||||
if (state.applyCacheTtl) {
|
||||
const parsed = parseIntegerField(state.cacheTtlMinutes, '缓存 TTL', 0, 60)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.cache_ttl_minutes = parsed.value as number
|
||||
fieldLabels.push('缓存 TTL')
|
||||
}
|
||||
|
||||
if (state.applyProbeInterval) {
|
||||
const parsed = parseIntegerField(state.maxProbeIntervalMinutes, '熔断探测', 0, 32)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.max_probe_interval_minutes = parsed.value as number
|
||||
fieldLabels.push('熔断探测')
|
||||
}
|
||||
|
||||
if (state.applyNote) {
|
||||
patch.note = state.note.trim() || null
|
||||
fieldLabels.push('备注')
|
||||
}
|
||||
|
||||
if (state.applyModels) {
|
||||
if (!state.modelMode) {
|
||||
return { patch: null, fieldLabels, error: '请选择模型权限管理方式' }
|
||||
}
|
||||
const selectedModels = uniqueTrimmed(state.selectedModels)
|
||||
if (state.modelMode === 'manual') {
|
||||
if (!state.unrestrictedModels && selectedModels.length === 0) {
|
||||
return { patch: null, fieldLabels, error: '请至少选择一个允许的模型' }
|
||||
}
|
||||
patch.auto_fetch_models = false
|
||||
patch.allowed_models = state.unrestrictedModels ? null : selectedModels
|
||||
patch.locked_models = []
|
||||
patch.model_include_patterns = []
|
||||
patch.model_exclude_patterns = []
|
||||
} else {
|
||||
patch.auto_fetch_models = true
|
||||
patch.locked_models = selectedModels
|
||||
patch.model_include_patterns = parsePoolKeyModelPatterns(state.includePatterns)
|
||||
patch.model_exclude_patterns = parsePoolKeyModelPatterns(state.excludePatterns)
|
||||
}
|
||||
fieldLabels.push('模型权限')
|
||||
}
|
||||
|
||||
if (fieldLabels.length === 0) {
|
||||
return { patch: null, fieldLabels, error: '请至少启用一个批量编辑字段' }
|
||||
}
|
||||
|
||||
return { patch, fieldLabels, error: null }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const adminApiMocks = vi.hoisted(() => ({
|
||||
queryProviderModels: vi.fn(),
|
||||
queryProviderModelsForKeys: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({ adminApi: adminApiMocks }))
|
||||
|
||||
import { useUpstreamModelsCache } from '../useUpstreamModelsCache'
|
||||
|
||||
function response(modelId: string) {
|
||||
return {
|
||||
success: true,
|
||||
data: { models: [{ id: modelId }] },
|
||||
provider: { id: 'provider-1', name: 'Provider', display_name: 'Provider' },
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useUpstreamModelsCache', () => {
|
||||
beforeEach(() => {
|
||||
adminApiMocks.queryProviderModels.mockReset()
|
||||
adminApiMocks.queryProviderModelsForKeys.mockReset()
|
||||
})
|
||||
|
||||
it('deduplicates equivalent multi-key model requests', async () => {
|
||||
const request = deferred<ReturnType<typeof response>>()
|
||||
adminApiMocks.queryProviderModelsForKeys.mockReturnValue(request.promise)
|
||||
const { fetchModelsForKeys } = useUpstreamModelsCache()
|
||||
|
||||
const first = fetchModelsForKeys('provider-1', ['key-b', 'key-a', 'key-a'])
|
||||
const second = fetchModelsForKeys('provider-1', ['key-a', 'key-b'])
|
||||
expect(adminApiMocks.queryProviderModelsForKeys).toHaveBeenCalledTimes(1)
|
||||
expect(adminApiMocks.queryProviderModelsForKeys).toHaveBeenCalledWith(
|
||||
'provider-1',
|
||||
['key-a', 'key-b'],
|
||||
false,
|
||||
)
|
||||
|
||||
request.resolve(response('gpt-5.6-sol'))
|
||||
await expect(first).resolves.toMatchObject({ models: [{ id: 'gpt-5.6-sol' }] })
|
||||
await expect(second).resolves.toMatchObject({ models: [{ id: 'gpt-5.6-sol' }] })
|
||||
})
|
||||
|
||||
it('keeps the loading state owned by the latest forced request', async () => {
|
||||
const firstRequest = deferred<ReturnType<typeof response>>()
|
||||
const forcedRequest = deferred<ReturnType<typeof response>>()
|
||||
adminApiMocks.queryProviderModels
|
||||
.mockReturnValueOnce(firstRequest.promise)
|
||||
.mockReturnValueOnce(forcedRequest.promise)
|
||||
const { fetchModels, isLoading } = useUpstreamModelsCache()
|
||||
|
||||
const first = fetchModels('provider-1', 'key-a')
|
||||
const forced = fetchModels('provider-1', 'key-a', true)
|
||||
expect(isLoading('provider-1', 'key-a')).toBe(true)
|
||||
|
||||
firstRequest.resolve(response('gpt-5.6-sol'))
|
||||
await first
|
||||
expect(isLoading('provider-1', 'key-a')).toBe(true)
|
||||
|
||||
forcedRequest.resolve(response('gpt-5.6-luna'))
|
||||
await forced
|
||||
expect(isLoading('provider-1', 'key-a')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { adminApi, type ProviderModelsQueryResponse } from '@/api/admin'
|
||||
import { parseUpstreamModelError } from '@/utils/errorParser'
|
||||
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||
|
||||
@@ -15,6 +15,8 @@ type FetchResult = { models: UpstreamModel[]; error?: string; warning?: string;
|
||||
|
||||
// 进行中的请求(用于去重并发请求)
|
||||
const pendingRequests = new Map<string, Promise<FetchResult>>()
|
||||
const activeRequestIds = new Map<string, number>()
|
||||
let nextRequestId = 0
|
||||
|
||||
// 请求状态
|
||||
const loadingMap = ref<Map<string, boolean>>(new Map())
|
||||
@@ -26,6 +28,57 @@ function getRequestKey(providerId: string, apiKeyId?: string): string {
|
||||
return apiKeyId ? `${providerId}:${apiKeyId}` : providerId
|
||||
}
|
||||
|
||||
function getBatchRequestKey(providerId: string, apiKeyIds: string[]): string {
|
||||
return `${providerId}:batch:${JSON.stringify([...new Set(apiKeyIds)].sort())}`
|
||||
}
|
||||
|
||||
function providerModelsFetchResult(response: ProviderModelsQueryResponse): FetchResult {
|
||||
if (response.success && response.data?.models) {
|
||||
const partialWarning = response.data.warning ?? response.data.error
|
||||
return {
|
||||
models: response.data.models,
|
||||
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
|
||||
fromCache: response.data.from_cache,
|
||||
}
|
||||
}
|
||||
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
|
||||
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||
}
|
||||
|
||||
function fetchProviderModels(
|
||||
requestKey: string,
|
||||
forceRefresh: boolean,
|
||||
request: () => Promise<ProviderModelsQueryResponse>,
|
||||
): Promise<FetchResult> {
|
||||
if (!forceRefresh && pendingRequests.has(requestKey)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return pendingRequests.get(requestKey)!
|
||||
}
|
||||
|
||||
const requestId = ++nextRequestId
|
||||
activeRequestIds.set(requestKey, requestId)
|
||||
loadingMap.value.set(requestKey, true)
|
||||
const requestPromise = (async (): Promise<FetchResult> => {
|
||||
try {
|
||||
return providerModelsFetchResult(await request())
|
||||
} catch (err: unknown) {
|
||||
const rawError = isAxiosError(err)
|
||||
? (err.response?.data?.detail ?? err.message)
|
||||
: (err instanceof Error ? err.message : String(err))
|
||||
return { models: [], error: parseUpstreamModelError(rawError || '获取上游模型失败') }
|
||||
} finally {
|
||||
if (activeRequestIds.get(requestKey) === requestId) {
|
||||
loadingMap.value.set(requestKey, false)
|
||||
pendingRequests.delete(requestKey)
|
||||
activeRequestIds.delete(requestKey)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(requestKey, requestPromise)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
export function useUpstreamModelsCache() {
|
||||
/**
|
||||
* 获取上游模型列表
|
||||
@@ -40,41 +93,32 @@ export function useUpstreamModelsCache() {
|
||||
forceRefresh = false
|
||||
): Promise<FetchResult> {
|
||||
const requestKey = getRequestKey(providerId, apiKeyId)
|
||||
return fetchProviderModels(
|
||||
requestKey,
|
||||
forceRefresh,
|
||||
() => adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh),
|
||||
)
|
||||
}
|
||||
|
||||
// 强制刷新时不复用进行中的请求
|
||||
if (!forceRefresh && pendingRequests.has(requestKey)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return pendingRequests.get(requestKey)!
|
||||
async function fetchModelsForKeys(
|
||||
providerId: string,
|
||||
apiKeyIds: string[],
|
||||
forceRefresh = false
|
||||
): Promise<FetchResult> {
|
||||
const normalizedKeyIds = [...new Set(apiKeyIds.map(id => id.trim()).filter(Boolean))].sort()
|
||||
if (normalizedKeyIds.length === 0) {
|
||||
return { models: [], error: '请先选择账号' }
|
||||
}
|
||||
|
||||
// 创建新请求
|
||||
const requestPromise = (async (): Promise<FetchResult> => {
|
||||
try {
|
||||
loadingMap.value.set(requestKey, true)
|
||||
const response = await adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh)
|
||||
|
||||
if (response.success && response.data?.models) {
|
||||
const partialWarning = response.data.warning ?? response.data.error
|
||||
return {
|
||||
models: response.data.models,
|
||||
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
|
||||
fromCache: response.data.from_cache
|
||||
}
|
||||
} else {
|
||||
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
|
||||
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const rawError = isAxiosError(err) ? (err.response?.data?.detail ?? err.message) : (err instanceof Error ? err.message : String(err))
|
||||
return { models: [], error: parseUpstreamModelError(rawError || '获取上游模型失败') }
|
||||
} finally {
|
||||
loadingMap.value.set(requestKey, false)
|
||||
pendingRequests.delete(requestKey)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(requestKey, requestPromise)
|
||||
return requestPromise
|
||||
const requestKey = getBatchRequestKey(providerId, normalizedKeyIds)
|
||||
return fetchProviderModels(
|
||||
requestKey,
|
||||
forceRefresh,
|
||||
() => adminApi.queryProviderModelsForKeys(
|
||||
providerId,
|
||||
normalizedKeyIds,
|
||||
forceRefresh,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +131,7 @@ export function useUpstreamModelsCache() {
|
||||
|
||||
return {
|
||||
fetchModels,
|
||||
fetchModelsForKeys,
|
||||
isLoading,
|
||||
loadingMap
|
||||
}
|
||||
|
||||
@@ -1207,6 +1207,7 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'模型管理': 'Model management',
|
||||
'提供商管理': 'Provider management',
|
||||
'号池管理': 'Pool management',
|
||||
'密钥批量管理': 'Bulk key management',
|
||||
'模块管理': 'Module management',
|
||||
'系统扩展': 'System extensions',
|
||||
'Aether 提供高可插入的模块化管理机制,帮助连接外部服务和授权系统。': 'Aether provides a highly pluggable module management system for connecting external services and authorization systems.',
|
||||
|
||||
@@ -936,6 +936,17 @@
|
||||
:provider-type="selectedProviderData?.provider_type || selectedProviderType"
|
||||
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
|
||||
@changed="handleAccountBatchChanged"
|
||||
@edit-config="openKeyBatchEditDialog"
|
||||
/>
|
||||
<PoolKeyBatchEditDialog
|
||||
v-if="selectedProviderId"
|
||||
:open="keyBatchEditDialogOpen"
|
||||
:provider-id="selectedProviderId"
|
||||
:provider-name="selectedProviderData?.name || ''"
|
||||
:key-ids="keyBatchEditKeyIds"
|
||||
:available-api-formats="selectedProviderData?.api_formats || []"
|
||||
@close="closeKeyBatchEditDialog"
|
||||
@saved="handleKeyBatchEditSaved"
|
||||
/>
|
||||
<KeyFormDialog
|
||||
v-if="selectedProviderId"
|
||||
@@ -1042,6 +1053,7 @@ import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialo
|
||||
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
|
||||
import PoolDemandMetricsDialog from '@/features/pool/components/PoolDemandMetricsDialog.vue'
|
||||
import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue'
|
||||
import PoolKeyBatchEditDialog from '@/features/pool/components/PoolKeyBatchEditDialog.vue'
|
||||
import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue'
|
||||
import PoolKeyQuotaPanel from '@/features/pool/components/PoolKeyQuotaPanel.vue'
|
||||
import PoolKeyStatsPanel from '@/features/pool/components/PoolKeyStatsPanel.vue'
|
||||
@@ -1248,6 +1260,8 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
|
||||
endpointEditDialogOpen.value = false
|
||||
providerEndpointsForEdit.value = []
|
||||
showAccountBatchDialog.value = false
|
||||
keyBatchEditDialogOpen.value = false
|
||||
keyBatchEditKeyIds.value = []
|
||||
closeProviderProxyPopovers()
|
||||
resetKeyPage()
|
||||
}
|
||||
@@ -1610,6 +1624,8 @@ async function selectProvider(
|
||||
providerEndpointsForEdit.value = []
|
||||
editingKeyDetail.value = null
|
||||
showAccountBatchDialog.value = false
|
||||
keyBatchEditDialogOpen.value = false
|
||||
keyBatchEditKeyIds.value = []
|
||||
keyPermissionsDialogOpen.value = false
|
||||
keyFormDialogOpen.value = false
|
||||
oauthKeyEditDialogOpen.value = false
|
||||
@@ -1691,6 +1707,8 @@ const prioritySavingKeyId = ref<string | null>(null)
|
||||
|
||||
const keyPermissionsDialogOpen = ref(false)
|
||||
const keyFormDialogOpen = ref(false)
|
||||
const keyBatchEditDialogOpen = ref(false)
|
||||
const keyBatchEditKeyIds = ref<string[]>([])
|
||||
const oauthKeyEditDialogOpen = ref(false)
|
||||
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
|
||||
|
||||
@@ -2382,6 +2400,20 @@ function handleKeyPermissions(key: PoolKeyDetail) {
|
||||
keyPermissionsDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openKeyBatchEditDialog(keyIds: string[]): void {
|
||||
keyBatchEditKeyIds.value = [...new Set(keyIds)]
|
||||
keyBatchEditDialogOpen.value = keyBatchEditKeyIds.value.length > 0
|
||||
}
|
||||
|
||||
function closeKeyBatchEditDialog(): void {
|
||||
keyBatchEditDialogOpen.value = false
|
||||
keyBatchEditKeyIds.value = []
|
||||
}
|
||||
|
||||
async function handleKeyBatchEditSaved(): Promise<void> {
|
||||
await Promise.all([loadKeys(), loadOverview()])
|
||||
}
|
||||
|
||||
async function handleDialogSaved() {
|
||||
editingKeyDetail.value = null
|
||||
await loadKeys()
|
||||
|
||||
Reference in New Issue
Block a user