2026-04-07 12:15:19 +08:00
|
|
|
use crate::handlers::admin::provider::shared::support::{
|
2026-04-07 02:50:19 +08:00
|
|
|
put_admin_provider_delete_task, ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT,
|
|
|
|
|
ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS, ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS,
|
2026-04-04 01:40:24 +08:00
|
|
|
};
|
2026-04-09 00:10:38 +08:00
|
|
|
use crate::handlers::admin::request::AdminAppState;
|
2026-05-05 18:27:36 +08:00
|
|
|
use crate::handlers::admin::shared::{
|
|
|
|
|
decrypt_catalog_secret_with_fallbacks, json_string_list, take_secret_prefix, take_secret_suffix,
|
|
|
|
|
};
|
2026-04-07 02:50:19 +08:00
|
|
|
use crate::handlers::public::matches_model_mapping_for_models;
|
2026-04-09 00:10:38 +08:00
|
|
|
use crate::{GatewayError, LocalProviderDeleteTaskState};
|
2026-04-07 02:50:19 +08:00
|
|
|
use aether_data_contracts::repository::global_models::{
|
2026-04-14 21:58:52 +08:00
|
|
|
AdminGlobalModelListQuery, AdminProviderModelListQuery, PublicGlobalModelQuery,
|
2026-04-04 01:40:24 +08:00
|
|
|
};
|
2026-04-07 02:50:19 +08:00
|
|
|
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
2026-04-04 01:40:24 +08:00
|
|
|
use serde_json::json;
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
2026-04-14 21:58:52 +08:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct MappingPreviewGlobalModel {
|
|
|
|
|
id: String,
|
|
|
|
|
name: String,
|
|
|
|
|
display_name: String,
|
|
|
|
|
is_active: bool,
|
|
|
|
|
mappings: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
pub(crate) async fn run_admin_provider_delete_task(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-03-31 19:19:04 +08:00
|
|
|
provider_id: &str,
|
|
|
|
|
task_id: &str,
|
|
|
|
|
) -> Result<LocalProviderDeleteTaskState, GatewayError> {
|
2026-04-09 00:10:38 +08:00
|
|
|
let app = state.as_ref();
|
2026-03-31 19:19:04 +08:00
|
|
|
let Some(mut provider) = state
|
|
|
|
|
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
|
|
|
|
.await?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.next()
|
|
|
|
|
else {
|
|
|
|
|
return Err(GatewayError::Internal(format!(
|
|
|
|
|
"provider {provider_id} not found for delete task"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let keys = state
|
|
|
|
|
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
|
|
|
|
.await?;
|
|
|
|
|
let endpoints = state
|
|
|
|
|
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
|
|
|
|
.await?;
|
|
|
|
|
let models = state
|
|
|
|
|
.list_admin_provider_models(&AdminProviderModelListQuery {
|
|
|
|
|
provider_id: provider.id.clone(),
|
|
|
|
|
offset: 0,
|
|
|
|
|
limit: 10_000,
|
|
|
|
|
is_active: None,
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let mut task = LocalProviderDeleteTaskState {
|
|
|
|
|
task_id: task_id.to_string(),
|
|
|
|
|
provider_id: provider.id.clone(),
|
|
|
|
|
status: "running".to_string(),
|
|
|
|
|
stage: "preparing".to_string(),
|
|
|
|
|
total_keys: keys.len(),
|
|
|
|
|
deleted_keys: 0,
|
|
|
|
|
total_endpoints: endpoints.len(),
|
|
|
|
|
deleted_endpoints: 0,
|
|
|
|
|
message: format!(
|
|
|
|
|
"preparing delete for {} keys and {} endpoints",
|
|
|
|
|
keys.len(),
|
|
|
|
|
endpoints.len()
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
|
|
|
|
|
if provider.is_active {
|
|
|
|
|
provider.is_active = false;
|
|
|
|
|
provider.updated_at_unix_secs = Some(
|
|
|
|
|
SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|duration| duration.as_secs())
|
|
|
|
|
.unwrap_or(0),
|
|
|
|
|
);
|
2026-04-09 00:10:38 +08:00
|
|
|
let _ = app.update_provider_catalog_provider(&provider).await?;
|
2026-03-31 19:19:04 +08:00
|
|
|
}
|
|
|
|
|
task.stage = "disabling".to_string();
|
|
|
|
|
task.message = "provider disabled; starting cleanup".to_string();
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
|
|
|
|
|
let endpoint_ids = endpoints
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|item| item.id.clone())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let key_ids = keys.iter().map(|item| item.id.clone()).collect::<Vec<_>>();
|
2026-04-09 00:10:38 +08:00
|
|
|
app.cleanup_deleted_provider_catalog_refs(&provider.id, &endpoint_ids, &key_ids)
|
2026-03-31 19:19:04 +08:00
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
task.stage = "deleting_models".to_string();
|
|
|
|
|
task.message = format!("deleting {} provider models", models.len());
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
for model in &models {
|
|
|
|
|
let _ = state
|
|
|
|
|
.delete_admin_provider_model(&provider.id, &model.id)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task.stage = "deleting_keys".to_string();
|
|
|
|
|
task.message = "deleting provider keys".to_string();
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
for key in &keys {
|
|
|
|
|
if state.delete_provider_catalog_key(&key.id).await? {
|
|
|
|
|
task.deleted_keys += 1;
|
|
|
|
|
task.message = format!("deleted {} / {} keys", task.deleted_keys, task.total_keys);
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task.stage = "deleting_endpoints".to_string();
|
|
|
|
|
task.message = "deleting provider endpoints".to_string();
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
for endpoint in &endpoints {
|
|
|
|
|
if state.delete_provider_catalog_endpoint(&endpoint.id).await? {
|
|
|
|
|
task.deleted_endpoints += 1;
|
|
|
|
|
task.message = format!(
|
|
|
|
|
"deleted {} / {} endpoints",
|
|
|
|
|
task.deleted_endpoints, task.total_endpoints
|
|
|
|
|
);
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task.stage = "deleting_provider".to_string();
|
|
|
|
|
task.message = "deleting provider record".to_string();
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
2026-04-09 00:10:38 +08:00
|
|
|
if !app.delete_provider_catalog_provider(&provider.id).await? {
|
2026-03-31 19:19:04 +08:00
|
|
|
task.status = "failed".to_string();
|
|
|
|
|
task.stage = "failed".to_string();
|
|
|
|
|
task.message = "provider delete failed".to_string();
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
return Ok(task);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task.status = "completed".to_string();
|
|
|
|
|
task.stage = "completed".to_string();
|
|
|
|
|
task.message = format!(
|
|
|
|
|
"provider deleted: keys={}, endpoints={}",
|
|
|
|
|
task.deleted_keys, task.deleted_endpoints
|
|
|
|
|
);
|
|
|
|
|
put_admin_provider_delete_task(state, &task);
|
|
|
|
|
Ok(task)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 21:58:52 +08:00
|
|
|
pub(crate) fn global_model_mapping_patterns_from_config(
|
|
|
|
|
config: Option<&serde_json::Value>,
|
|
|
|
|
) -> Vec<String> {
|
|
|
|
|
config
|
2026-03-31 19:19:04 +08:00
|
|
|
.and_then(serde_json::Value::as_object)
|
|
|
|
|
.and_then(|config| config.get("model_mappings"))
|
|
|
|
|
.and_then(serde_json::Value::as_array)
|
|
|
|
|
.into_iter()
|
|
|
|
|
.flatten()
|
|
|
|
|
.filter_map(serde_json::Value::as_str)
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
pub(crate) fn mapping_preview_masked_catalog_api_key(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-03-31 19:19:04 +08:00
|
|
|
key: &StoredProviderCatalogKey,
|
|
|
|
|
) -> String {
|
2026-04-29 15:46:50 +08:00
|
|
|
let ciphertext = key.encrypted_api_key.as_deref().unwrap_or("").trim();
|
2026-03-31 19:19:04 +08:00
|
|
|
if ciphertext.is_empty() {
|
|
|
|
|
return "***".to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
|
|
|
|
|
.map(|value| {
|
2026-05-05 18:27:36 +08:00
|
|
|
let char_count = value.chars().count();
|
|
|
|
|
if char_count > 8 {
|
2026-03-31 19:19:04 +08:00
|
|
|
format!(
|
|
|
|
|
"{}***{}",
|
2026-05-05 18:27:36 +08:00
|
|
|
take_secret_prefix(&value, 4),
|
|
|
|
|
take_secret_suffix(&value, 4)
|
2026-03-31 19:19:04 +08:00
|
|
|
)
|
2026-05-05 18:27:36 +08:00
|
|
|
} else if char_count >= 2 {
|
|
|
|
|
format!("{}***", take_secret_prefix(&value, 2))
|
2026-03-31 19:19:04 +08:00
|
|
|
} else {
|
|
|
|
|
"***".to_string()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_else(|| "***".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
pub(crate) async fn build_admin_provider_mapping_preview_payload(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-03-31 19:19:04 +08:00
|
|
|
provider_id: &str,
|
|
|
|
|
) -> Option<serde_json::Value> {
|
2026-04-09 00:10:38 +08:00
|
|
|
let app = state.as_ref();
|
|
|
|
|
if !app.has_provider_catalog_data_reader() || !app.has_global_model_data_reader() {
|
2026-03-31 19:19:04 +08:00
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 00:10:38 +08:00
|
|
|
let provider = app
|
2026-03-31 19:19:04 +08:00
|
|
|
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
|
|
|
|
|
.await
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|mut providers| providers.drain(..).next())?;
|
|
|
|
|
|
2026-04-09 00:10:38 +08:00
|
|
|
let mut keys = app
|
2026-03-31 19:19:04 +08:00
|
|
|
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
|
|
|
|
.await
|
|
|
|
|
.ok()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|key| key.allowed_models.is_some())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let total_keys_with_allowed_models = keys.len();
|
|
|
|
|
let truncated_keys =
|
|
|
|
|
total_keys_with_allowed_models.saturating_sub(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS);
|
|
|
|
|
if keys.len() > ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS {
|
|
|
|
|
keys.truncate(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_KEYS);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 21:58:52 +08:00
|
|
|
let admin_models = state
|
|
|
|
|
.list_admin_global_models(&AdminGlobalModelListQuery {
|
2026-03-31 19:19:04 +08:00
|
|
|
offset: 0,
|
|
|
|
|
limit: ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT,
|
|
|
|
|
is_active: None,
|
|
|
|
|
search: None,
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.ok()
|
2026-04-07 02:50:19 +08:00
|
|
|
.unwrap_or_else(|| {
|
2026-04-14 21:58:52 +08:00
|
|
|
aether_data_contracts::repository::global_models::StoredAdminGlobalModelPage {
|
2026-03-31 19:19:04 +08:00
|
|
|
items: Vec::new(),
|
|
|
|
|
total: 0,
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
})
|
2026-03-31 19:19:04 +08:00
|
|
|
.items;
|
2026-04-14 21:58:52 +08:00
|
|
|
let mut models_with_mappings = admin_models
|
2026-03-31 19:19:04 +08:00
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|model| {
|
2026-04-14 21:58:52 +08:00
|
|
|
let mappings = global_model_mapping_patterns_from_config(model.config.as_ref());
|
|
|
|
|
(!mappings.is_empty()).then_some(MappingPreviewGlobalModel {
|
|
|
|
|
id: model.id,
|
|
|
|
|
name: model.name,
|
|
|
|
|
display_name: model.display_name,
|
|
|
|
|
is_active: model.is_active,
|
|
|
|
|
mappings,
|
|
|
|
|
})
|
2026-03-31 19:19:04 +08:00
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
2026-04-14 21:58:52 +08:00
|
|
|
if models_with_mappings.is_empty() {
|
|
|
|
|
let public_models = app
|
|
|
|
|
.list_public_global_models(&PublicGlobalModelQuery {
|
|
|
|
|
offset: 0,
|
|
|
|
|
limit: ADMIN_PROVIDER_MAPPING_PREVIEW_FETCH_LIMIT,
|
|
|
|
|
is_active: None,
|
|
|
|
|
search: None,
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.ok()
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
aether_data_contracts::repository::global_models::StoredPublicGlobalModelPage {
|
|
|
|
|
items: Vec::new(),
|
|
|
|
|
total: 0,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.items;
|
|
|
|
|
models_with_mappings = public_models
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|model| {
|
|
|
|
|
let mappings = global_model_mapping_patterns_from_config(model.config.as_ref());
|
|
|
|
|
(!mappings.is_empty()).then_some(MappingPreviewGlobalModel {
|
|
|
|
|
id: model.id,
|
|
|
|
|
name: model.name.clone(),
|
|
|
|
|
display_name: model.display_name.unwrap_or(model.name),
|
|
|
|
|
is_active: model.is_active,
|
|
|
|
|
mappings,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
}
|
2026-03-31 19:19:04 +08:00
|
|
|
let total_models_with_mappings = models_with_mappings.len();
|
|
|
|
|
let truncated_models =
|
|
|
|
|
total_models_with_mappings.saturating_sub(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS);
|
|
|
|
|
if models_with_mappings.len() > ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS {
|
|
|
|
|
models_with_mappings.truncate(ADMIN_PROVIDER_MAPPING_PREVIEW_MAX_MODELS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if models_with_mappings.is_empty() {
|
|
|
|
|
return Some(json!({
|
|
|
|
|
"provider_id": provider.id,
|
|
|
|
|
"provider_name": provider.name,
|
|
|
|
|
"keys": [],
|
|
|
|
|
"total_keys": 0,
|
|
|
|
|
"total_matches": 0,
|
|
|
|
|
"truncated": truncated_keys > 0 || truncated_models > 0,
|
|
|
|
|
"truncated_keys": truncated_keys,
|
|
|
|
|
"truncated_models": truncated_models,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut key_payloads = Vec::new();
|
|
|
|
|
let mut total_matches = 0_u64;
|
|
|
|
|
for key in keys {
|
|
|
|
|
let allowed_models = json_string_list(key.allowed_models.as_ref());
|
|
|
|
|
if allowed_models.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut matching_global_models = Vec::new();
|
2026-04-14 21:58:52 +08:00
|
|
|
for global_model in &models_with_mappings {
|
2026-03-31 19:19:04 +08:00
|
|
|
let mut matched_models = Vec::new();
|
|
|
|
|
for allowed_model in &allowed_models {
|
2026-04-14 21:58:52 +08:00
|
|
|
for mapping_pattern in &global_model.mappings {
|
2026-03-31 19:19:04 +08:00
|
|
|
if matches_model_mapping_for_models(mapping_pattern, allowed_model) {
|
|
|
|
|
matched_models.push(json!({
|
|
|
|
|
"allowed_model": allowed_model,
|
|
|
|
|
"mapping_pattern": mapping_pattern,
|
|
|
|
|
}));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !matched_models.is_empty() {
|
|
|
|
|
matching_global_models.push(json!({
|
|
|
|
|
"global_model_id": global_model.id,
|
|
|
|
|
"global_model_name": global_model.name,
|
2026-04-14 21:58:52 +08:00
|
|
|
"display_name": global_model.display_name,
|
2026-03-31 19:19:04 +08:00
|
|
|
"is_active": global_model.is_active,
|
|
|
|
|
"matched_models": matched_models,
|
|
|
|
|
}));
|
|
|
|
|
total_matches = total_matches.saturating_add(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if matching_global_models.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
key_payloads.push(json!({
|
|
|
|
|
"key_id": key.id,
|
|
|
|
|
"key_name": key.name,
|
|
|
|
|
"masked_key": mapping_preview_masked_catalog_api_key(state, &key),
|
|
|
|
|
"is_active": key.is_active,
|
|
|
|
|
"allowed_models": allowed_models,
|
|
|
|
|
"matching_global_models": matching_global_models,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(json!({
|
|
|
|
|
"provider_id": provider.id,
|
|
|
|
|
"provider_name": provider.name,
|
|
|
|
|
"keys": key_payloads,
|
|
|
|
|
"total_keys": key_payloads.len(),
|
|
|
|
|
"total_matches": total_matches,
|
|
|
|
|
"truncated": truncated_keys > 0 || truncated_models > 0,
|
|
|
|
|
"truncated_keys": truncated_keys,
|
|
|
|
|
"truncated_models": truncated_models,
|
|
|
|
|
}))
|
|
|
|
|
}
|