feat: unify provider scheduling workspace

This commit is contained in:
elky
2026-10-07 00:34:18 +08:00
parent e7de935e61
commit 466c7918a1
69 changed files with 5810 additions and 2666 deletions
@@ -1892,6 +1892,84 @@ mod tests {
.is_none());
}
#[tokio::test]
async fn routing_policy_excludes_group_disabled_providers_from_candidate_pages() {
let repository: Arc<dyn MinimalCandidateSelectionReadRepository> =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([
standard_candidate_row("provider-disabled", "openai:chat", 0),
standard_candidate_row("provider-enabled", "openai:chat", 1),
]));
let app = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(
GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository),
);
let auth_snapshot = unrestricted_auth_snapshot();
let model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::load(&app).await;
let config = serde_json::from_value(serde_json::json!({
"disabled_providers": ["provider-disabled"],
"model_policies": [{
"model": "*",
"allowed_providers": ["provider-disabled", "provider-enabled"]
}]
}))
.expect("routing config should parse");
let routing_policy = aether_routing_core::resolve_routing_policy(
&config,
aether_routing_core::RoutingPolicyInput {
group_id: Some("routing-group-1"),
group_version: Some(1),
selection_source: "test",
requested_model: "gpt-5",
resolved_model: "gpt-5",
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &serde_json::json!({}),
body: &serde_json::json!({}),
phase: aether_routing_core::RoutingRulePhase::ClientRequest,
},
)
.expect("routing policy should resolve");
let mut cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
&model_directive_policy,
"openai:chat",
"gpt-5",
None,
false,
None,
&auth_snapshot,
Some(&routing_policy),
None,
None,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
false,
None,
)
.await;
let page = cursor
.next_page()
.await
.expect("routing candidate scan should succeed")
.expect("the enabled provider should remain");
assert_eq!(
page.candidates
.iter()
.map(|candidate| candidate.provider_id.as_str())
.collect::<Vec<_>>(),
vec!["provider-enabled"]
);
assert!(cursor
.next_page()
.await
.expect("routing scan should finish")
.is_none());
}
#[tokio::test]
async fn routing_policy_collects_candidate_pages_before_final_ranking() {
let rows = (0..300)
@@ -1735,7 +1735,7 @@ mod tests {
assert_eq!(policy.group_version, Some(4));
assert_eq!(
policy.priority_mode,
aether_routing_core::RoutingSetPriorityMode::GlobalKey
aether_routing_core::RoutingSetPriorityMode::Provider
);
assert_eq!(
policy.scheduling_mode,
@@ -562,6 +562,30 @@ impl GatewayDataState {
Ok(created)
}
pub(crate) async fn create_provider_catalog_provider_in_routing_group(
&self,
provider: &StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
routing_group_id: &str,
) -> Result<Option<StoredProviderCatalogProvider>, DataLayerError> {
let created = match &self.provider_catalog_writer {
Some(repository) => repository
.create_provider_in_routing_group(
provider,
shift_existing_priorities_from,
routing_group_id,
)
.await
.map(Some),
None => Ok(None),
}?;
if created.is_some() {
self.clear_provider_catalog_cache();
self.clear_routing_group_cache();
}
Ok(created)
}
pub(crate) async fn update_provider_catalog_provider(
&self,
provider: &StoredProviderCatalogProvider,
@@ -62,6 +62,16 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
)));
}
};
let routing_group_id = payload
.routing_group_id
.as_deref()
.map(str::trim)
.map(str::to_string);
if routing_group_id.as_deref() == Some("") {
return Ok(Some(build_admin_provider_bad_request_response(
"routing_group_id 不能为空",
)));
}
let (record, shift_existing_priorities_from) =
match state.build_admin_create_provider_record(payload).await {
Ok(record) => record,
@@ -69,10 +79,23 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
return Ok(Some(build_admin_provider_bad_request_response(message)));
}
};
let Some(created_provider) = state
.create_provider_catalog_provider(&record, shift_existing_priorities_from)
.await?
else {
let created = match routing_group_id.as_deref() {
Some(group_id) => {
state
.create_provider_catalog_provider_in_routing_group(
&record,
shift_existing_priorities_from,
group_id,
)
.await?
}
None => {
state
.create_provider_catalog_provider(&record, shift_existing_priorities_from)
.await?
}
};
let Some(created_provider) = created else {
return Ok(Some(build_admin_providers_data_unavailable_response()));
};
@@ -179,6 +179,8 @@ pub(crate) struct AdminCodexResetCreditConsumeRequest {
pub(crate) struct AdminProviderCreateRequest {
pub(crate) name: String,
#[serde(default)]
pub(crate) routing_group_id: Option<String>,
#[serde(default)]
pub(crate) provider_type: Option<String>,
#[serde(default)]
pub(crate) description: Option<String>,
@@ -420,6 +420,24 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn create_provider_catalog_provider_in_routing_group(
&self,
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
routing_group_id: &str,
) -> Result<
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider>,
GatewayError,
> {
self.app
.create_provider_catalog_provider_in_routing_group(
provider,
shift_existing_priorities_from,
routing_group_id,
)
.await
}
pub(crate) async fn update_provider_catalog_provider(
&self,
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
@@ -350,6 +350,7 @@ async fn publish_routing_group(
.update_routing_group(
group_id,
UpdateRoutingGroupRecord {
expected_version: Some(group.version),
version: Some(next_version),
updated_at: now,
published_at: Some(Some(now)),
@@ -455,6 +456,9 @@ fn build_routing_group_update_patch(
updated_at: current_unix_secs() as i64,
..UpdateRoutingGroupRecord::default()
};
if let Some(value) = object.get("expected_version") {
patch.expected_version = Some(required_i64(value, "expected_version")?);
}
if let Some(value) = object.get("name") {
patch.name = Some(required_string(value, "name")?);
}
@@ -2909,6 +2909,10 @@ mod tests {
allowed_keys: vec!["key-other".to_string()],
..matching.clone()
};
let disabled_provider = aether_routing_core::RankingOverlay {
disabled_providers: vec!["provider-allowed".to_string()],
..matching.clone()
};
assert!(routing_overlay_allows_affinity_target(None, &target));
assert!(routing_overlay_allows_affinity_target(
@@ -2923,6 +2927,10 @@ mod tests {
Some(&wrong_key),
&target
));
assert!(!routing_overlay_allows_affinity_target(
Some(&disabled_provider),
&target
));
}
#[test]
+87 -6
View File
@@ -92,7 +92,8 @@ pub(crate) fn resolve_gateway_static_default_routing_policy(
selection_source: input.selection_source.to_string(),
requested_model: input.requested_model.to_string(),
resolved_model: input.resolved_model.to_string(),
priority_mode: default_policy.priority_mode,
// Match the full resolver even when a stored default still says global_key.
priority_mode: RoutingSetPriorityMode::Provider,
scheduling_mode: default_policy.scheduling_mode,
keep_priority_on_conversion: default_policy.keep_priority_on_conversion,
sticky_key_attempts: default_policy.sticky_key_attempts,
@@ -110,10 +111,11 @@ fn static_default_policy_fields(
let Some(object) = config_json.as_object() else {
return Ok(None);
};
// A strategy's default policy applies to every model. Only model policies
// and rules require the request-context-aware resolver; unknown legacy
// fields (including the removed group allowlist) are intentionally ignored.
if !routing_array_field_is_missing_or_empty(object, "model_policies")
// A strategy's default policy applies to every model. Provider exclusions,
// model policies and rules require the full resolver to build the overlay;
// unknown legacy fields (including the removed group allowlist) are ignored.
if !routing_array_field_is_missing_or_empty(object, "disabled_providers")
|| !routing_array_field_is_missing_or_empty(object, "model_policies")
|| !routing_array_field_is_missing_or_empty(object, "rules")
{
return Ok(None);
@@ -275,7 +277,7 @@ mod tests {
);
assert_eq!(
static_policy.priority_mode,
RoutingSetPriorityMode::GlobalKey
RoutingSetPriorityMode::Provider
);
assert_eq!(
static_policy.scheduling_mode,
@@ -313,6 +315,85 @@ mod tests {
assert!(policy.is_none());
}
#[test]
fn provider_exclusions_without_model_policies_or_rules_use_full_resolver() {
for model in ["model-a", "future-model"] {
let config = json!({
"disabled_providers": ["provider-disabled"],
"model_policies": [],
"rules": []
});
let static_policy = resolve_gateway_static_default_routing_policy(
GatewayStaticRoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
group_config_json: &config,
selection_source: "system_default",
requested_model: model,
resolved_model: model,
},
)
.expect("provider exclusions should defer to the full resolver");
assert!(static_policy.is_none());
let policy = resolve_gateway_routing_policy(GatewayRoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
group_config_json: &config,
selection_source: "system_default",
requested_model: model,
resolved_model: model,
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
})
.expect("group exclusions should resolve");
assert!(!policy.ranking_overlay.provider_allowed("provider-disabled"));
assert!(policy.ranking_overlay.provider_allowed("provider-enabled"));
}
}
#[test]
fn empty_provider_exclusions_preserve_static_default_fast_path() {
for config in [json!({}), json!({"disabled_providers": []})] {
assert!(static_default_policy_fields(&config)
.expect("empty provider exclusions should be valid")
.is_some());
}
}
#[test]
fn malformed_provider_exclusions_cannot_bypass_full_config_validation() {
for disabled in [json!("provider-disabled"), json!([42]), Value::Null] {
let config = json!({"disabled_providers": disabled});
let error = resolve_gateway_routing_policy(GatewayRoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
group_config_json: &config,
selection_source: "system_default",
requested_model: "model-a",
resolved_model: "model-a",
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
})
.expect_err("malformed provider exclusions must not be silently ignored");
assert!(matches!(
error,
GatewayError::Client {
status: StatusCode::BAD_REQUEST,
..
}
));
}
}
#[test]
fn routing_config_errors_do_not_echo_config_values() {
let secret = "https://internal.example/?token=Bearer-secret";
+35 -4
View File
@@ -102,8 +102,12 @@ impl SchedulerOrderingConfig {
fn scheduler_priority_mode_from_routing(mode: RoutingSetPriorityMode) -> SchedulerPriorityMode {
match mode {
RoutingSetPriorityMode::Provider => SchedulerPriorityMode::Provider,
RoutingSetPriorityMode::GlobalKey => SchedulerPriorityMode::GlobalKey,
// Defaults and previously resolved snapshots may still carry the
// removed global_key mode. Keep that compatibility at this boundary,
// without changing the scheduler's independent GlobalKey capability.
RoutingSetPriorityMode::Provider | RoutingSetPriorityMode::GlobalKey => {
SchedulerPriorityMode::Provider
}
}
}
@@ -161,6 +165,33 @@ mod tests {
use super::*;
use crate::data::GatewayDataState;
#[test]
fn legacy_resolved_key_snapshot_uses_provider_ordering() {
let snapshot: ResolvedRoutingPolicy = serde_json::from_value(json!({
"group_id": "legacy-group",
"selection_source": "system_default",
"requested_model": "model-a",
"resolved_model": "model-a",
"priority_mode": "global_key",
"scheduling_mode": "fixed_order",
"keep_priority_on_conversion": true,
"sticky_key_attempts": 4,
"ranking_overlay": { "key_priority_overrides": { "key-a": 2 } },
"mutation_plan": { "body_patch": [], "header_patch": [] }
}))
.expect("legacy snapshots must remain readable");
let ordering = SchedulerOrderingConfig::from_routing_policy(&snapshot);
assert_eq!(ordering.priority_mode, SchedulerPriorityMode::Provider);
assert_eq!(
ordering.scheduling_mode,
SchedulerSchedulingMode::FixedOrder
);
assert!(ordering.keep_priority_on_conversion);
assert_eq!(ordering.sticky_key_attempts, 4);
assert_eq!(snapshot.ranking_overlay.key_priority_overrides["key-a"], 2);
}
async fn create_system_default(
repository: &InMemoryRoutingGroupRepository,
enabled: bool,
@@ -185,14 +216,14 @@ mod tests {
}
#[tokio::test]
async fn system_default_routing_group_exposes_strategy_ordering() {
async fn legacy_system_default_routing_group_uses_provider_ordering() {
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
create_system_default(
&repository,
true,
json!({
"default_policy": {
"priority_mode": "provider",
"priority_mode": "global_key",
"scheduling_mode": "fixed_order",
"keep_priority_on_conversion": false
}
+38
View File
@@ -639,6 +639,44 @@ impl AppState {
}
}
pub(crate) async fn create_provider_catalog_provider_in_routing_group(
&self,
provider: &provider_catalog::StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
routing_group_id: &str,
) -> Result<Option<provider_catalog::StoredProviderCatalogProvider>, GatewayError> {
let protected = self.protect_provider_catalog_provider(provider)?;
let created = self
.data
.create_provider_catalog_provider_in_routing_group(
&protected,
shift_existing_priorities_from,
routing_group_id,
)
.await
.map_err(|err| match err {
aether_data_contracts::DataLayerError::InvalidInput(ref message)
if message == "routing_group_not_found" =>
{
GatewayError::Client {
status: axum::http::StatusCode::NOT_FOUND,
message: "策略分组不存在,请刷新后重试".to_string(),
}
}
_ => GatewayError::Internal(err.to_string()),
})?;
if created.is_some() {
self.invalidate_provider_routing_caches();
}
match created {
Some(provider) => self
.open_provider_catalog_provider(provider)
.await
.map(Some),
None => Ok(None),
}
}
pub(crate) async fn update_provider_catalog_provider(
&self,
provider: &provider_catalog::StoredProviderCatalogProvider,
@@ -160,7 +160,17 @@ impl AppState {
.data
.update_routing_group(id, patch)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
.map_err(|err| match err {
aether_data_contracts::DataLayerError::InvalidInput(ref message)
if message == "routing_group_version_conflict" =>
{
GatewayError::Client {
status: axum::http::StatusCode::CONFLICT,
message: "策略分组已被修改,请刷新后重试".to_string(),
}
}
_ => GatewayError::Internal(err.to_string()),
})?;
if updated.is_some() {
self.invalidate_provider_routing_caches();
}
@@ -42,6 +42,90 @@ const ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin provider catalog da
mod health;
#[tokio::test]
async fn gateway_creates_provider_in_selected_routing_group_and_rejects_stale_saves() {
use aether_data::repository::routing_profiles::InMemoryRoutingGroupRepository;
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupRecord, RoutingGroupReadRepository, RoutingGroupWriteRepository,
};
let groups = Arc::new(InMemoryRoutingGroupRepository::default());
for id in ["selected", "other"] {
groups
.create_routing_group(CreateRoutingGroupRecord {
id: id.into(),
name: id.into(),
description: None,
enabled: true,
is_system_default: id == "selected",
sort_order: 0,
config_json: json!({}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.unwrap();
}
let providers = Arc::new(
InMemoryProviderCatalogReadRepository::default().with_routing_groups(groups.clone()),
);
let gateway = build_router_with_state(
AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(providers.clone())
.with_routing_group_repository_for_tests(groups.clone()),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let request = |name: &str, group: &str| {
client
.post(format!("{gateway_url}/api/admin/providers/"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({"name": name, "routing_group_id": group}))
};
let missing = request("missing", "gone").send().await.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
assert!(providers.list_providers(false).await.unwrap().is_empty());
let response = request("scoped", "selected").send().await.unwrap();
let status = response.status();
let payload: serde_json::Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{payload}");
let provider_id = payload["id"].as_str().unwrap();
for group in groups.list_routing_groups().await.unwrap() {
assert_eq!(group.version, if group.id == "selected" { 1 } else { 2 });
assert_eq!(
group.config_json["disabled_providers"],
if group.id == "selected" {
json!(null)
} else {
json!([provider_id])
}
);
}
let response = client
.patch(format!("{gateway_url}/api/admin/routing/groups/other"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({"expected_version": 1, "config_json": {"disabled_providers": []}}))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::CONFLICT,
"{}",
response.text().await.unwrap()
);
gateway_handle.abort();
}
async fn provider_health_summary(
endpoints: &[StoredProviderCatalogEndpoint],
keys: &[StoredProviderCatalogKey],
@@ -1179,6 +1179,16 @@ WHERE id = $1
&self,
provider: &StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
) -> Result<StoredProviderCatalogProvider, DataLayerError> {
self.create_provider_with_routing_group(provider, shift_existing_priorities_from, None)
.await
}
async fn create_provider_with_routing_group(
&self,
provider: &StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
routing_group_id: Option<&str>,
) -> Result<StoredProviderCatalogProvider, DataLayerError> {
if provider.id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
@@ -1208,6 +1218,40 @@ WHERE id = $1
let mut tx = self.pool.begin().await.map_postgres_err()?;
if let Some(group_id) = routing_group_id {
// Group edits use the same lock: validation, exclusions, and provider
// creation are committed together, including concurrent deletions.
sqlx::query("LOCK TABLE routing_groups IN SHARE ROW EXCLUSIVE MODE")
.execute(&mut *tx)
.await
.map_postgres_err()?;
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM routing_groups WHERE id = $1)")
.bind(group_id)
.fetch_one(&mut *tx)
.await
.map_postgres_err()?;
if !exists {
return Err(DataLayerError::InvalidInput(
"routing_group_not_found".to_string(),
));
}
sqlx::query(r#"
UPDATE routing_groups
SET config_json = jsonb_set(config_json::jsonb, '{disabled_providers}',
CASE WHEN id = $1
THEN COALESCE(config_json::jsonb -> 'disabled_providers', '[]'::jsonb) - $2::text
ELSE (COALESCE(config_json::jsonb -> 'disabled_providers', '[]'::jsonb) - $2::text) || jsonb_build_array($2::text)
END),
version = version + 1,
updated_at = EXTRACT(EPOCH FROM NOW())::bigint
WHERE (id <> $1 AND NOT (COALESCE(config_json::jsonb -> 'disabled_providers', '[]'::jsonb) ? $2::text))
OR (id = $1 AND (COALESCE(config_json::jsonb -> 'disabled_providers', '[]'::jsonb) ? $2::text))
"#)
.bind(group_id).bind(&provider.id)
.execute(&mut *tx).await.map_postgres_err()?;
}
if let Some(target_priority) = shift_existing_priorities_from {
sqlx::query(
r#"
@@ -3015,6 +3059,20 @@ impl ProviderCatalogReadRepository for SqlxProviderCatalogReadRepository {
#[async_trait]
impl ProviderCatalogWriteRepository for SqlxProviderCatalogReadRepository {
async fn create_provider_in_routing_group(
&self,
provider: &StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
routing_group_id: &str,
) -> Result<StoredProviderCatalogProvider, DataLayerError> {
self.create_provider_with_routing_group(
provider,
shift_existing_priorities_from,
Some(routing_group_id),
)
.await
}
async fn create_provider(
&self,
provider: &StoredProviderCatalogProvider,
@@ -1463,6 +1463,19 @@ pub trait ProviderCatalogReadRepository: Send + Sync {
#[async_trait]
pub trait ProviderCatalogWriteRepository: Send + Sync {
/// Create a provider and exclude it from every other existing routing group
/// in one transaction. Implementations must fail closed if unsupported.
async fn create_provider_in_routing_group(
&self,
_provider: &StoredProviderCatalogProvider,
_shift_existing_priorities_from: Option<i32>,
_routing_group_id: &str,
) -> Result<StoredProviderCatalogProvider, crate::DataLayerError> {
Err(crate::DataLayerError::InvalidConfiguration(
"atomic provider creation in a routing group is not supported".to_string(),
))
}
async fn create_provider(
&self,
provider: &StoredProviderCatalogProvider,
@@ -58,6 +58,7 @@ pub struct CreateRoutingGroupRecord {
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UpdateRoutingGroupRecord {
pub expected_version: Option<i64>,
pub name: Option<String>,
pub description: Option<Option<String>>,
pub enabled: Option<bool>,
@@ -244,6 +245,16 @@ pub fn apply_group_patch(
group: &mut StoredRoutingGroup,
patch: UpdateRoutingGroupRecord,
) -> Result<(), crate::DataLayerError> {
if patch
.expected_version
.is_some_and(|version| version != group.version)
{
return Err(crate::DataLayerError::InvalidInput(
"routing_group_version_conflict".to_string(),
));
}
let previous_version = group.version;
let config_changed = patch.config_json.is_some();
if let Some(name) = patch.name {
if name.trim().is_empty() {
return Err(crate::DataLayerError::InvalidInput(
@@ -273,7 +284,13 @@ pub fn apply_group_patch(
group.config_json = config_json;
}
if let Some(version) = patch.version {
group.version = version.max(1);
group.version = if config_changed {
version.max(previous_version.saturating_add(1))
} else {
version.max(previous_version)
};
} else if config_changed {
group.version = previous_version.saturating_add(1);
}
if let Some(published_at) = patch.published_at {
group.published_at = published_at;
@@ -35,6 +35,7 @@ mod overview_fact_metadata;
mod overview_migration_safety;
mod policy_nulls;
mod provider_expenses;
mod scoped_provider_creation;
/// A clean PostgreSQL database is bootstrapped from the schema snapshot first;
/// migrations after the privacy/security frontier are intentionally left
@@ -0,0 +1,131 @@
use super::*;
use aether_data_contracts::repository::{
provider_catalog::{ProviderCatalogWriteRepository, StoredProviderCatalogProvider},
routing_profiles::{
CreateRoutingGroupRecord, RoutingGroupReadRepository, RoutingGroupWriteRepository,
UpdateRoutingGroupRecord,
},
};
use serde_json::json;
#[tokio::test]
async fn postgres_scoped_provider_creation_rolls_back_and_serializes_group_saves() {
let Some(server) = ManagedPostgresServer::try_start()
.await
.expect("local postgres should start or skip")
else {
return;
};
let pool = PgPool::connect(server.database_url()).await.unwrap();
prepare_and_apply_clean_postgres_database(&pool).await;
let groups =
crate::repository::routing_profiles::PostgresRoutingGroupRepository::new(pool.clone());
let providers =
crate::repository::provider_catalog::SqlxProviderCatalogReadRepository::new(pool.clone());
for id in ["selected", "other"] {
groups
.create_routing_group(CreateRoutingGroupRecord {
id: id.into(),
name: id.into(),
description: None,
enabled: true,
is_system_default: id == "selected",
sort_order: 0,
config_json: json!({"disabled_providers": ["existing-disabled"]}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.unwrap();
}
let provider =
StoredProviderCatalogProvider::new("new".into(), "new".into(), None, "custom".into())
.unwrap();
assert!(providers
.create_provider_in_routing_group(&provider, None, "missing")
.await
.is_err());
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM providers")
.fetch_one(&pool)
.await
.unwrap(),
0
);
assert!(groups
.list_routing_groups()
.await
.unwrap()
.iter()
.all(|group| group.version == 1));
providers
.create_provider_in_routing_group(&provider, None, "selected")
.await
.unwrap();
let before = groups.list_routing_groups().await.unwrap();
for group in &before {
assert_eq!(group.version, if group.id == "selected" { 1 } else { 2 });
assert_eq!(
group.config_json["disabled_providers"],
if group.id == "selected" {
json!(["existing-disabled"])
} else {
json!(["existing-disabled", "new"])
}
);
}
// The INSERT fails after group updates execute. Its transaction must undo
// every exclusion and version change along with any priority shifts.
assert!(providers
.create_provider_in_routing_group(&provider, Some(0), "other")
.await
.is_err());
assert_eq!(groups.list_routing_groups().await.unwrap(), before);
assert!(groups
.update_routing_group(
"other",
UpdateRoutingGroupRecord {
expected_version: Some(1),
config_json: Some(json!({"disabled_providers": []})),
..Default::default()
}
)
.await
.is_err());
assert_eq!(groups.list_routing_groups().await.unwrap(), before);
let concurrent_provider = StoredProviderCatalogProvider::new(
"concurrent".into(),
"concurrent".into(),
None,
"custom".into(),
)
.unwrap();
let (created, edited) = tokio::join!(
providers.create_provider_in_routing_group(&concurrent_provider, None, "selected"),
groups.update_routing_group(
"other",
UpdateRoutingGroupRecord {
expected_version: Some(2),
config_json: Some(json!({"disabled_providers": ["new"]})),
..Default::default()
}
)
);
created.unwrap();
let other = groups
.list_routing_groups()
.await
.unwrap()
.into_iter()
.find(|group| group.id == "other")
.unwrap();
assert!(other.config_json["disabled_providers"]
.as_array()
.unwrap()
.contains(&json!("concurrent")));
assert_eq!(other.version, if edited.is_ok() { 4 } else { 3 });
}
@@ -1,5 +1,5 @@
use std::collections::BTreeMap;
use std::sync::RwLock;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
@@ -31,6 +31,8 @@ struct MemoryProviderCatalogIndex {
#[derive(Debug, Default)]
pub struct InMemoryProviderCatalogReadRepository {
index: RwLock<MemoryProviderCatalogIndex>,
routing_groups:
Option<Arc<crate::repository::routing_profiles::InMemoryRoutingGroupRepository>>,
}
impl InMemoryProviderCatalogReadRepository {
@@ -40,6 +42,7 @@ impl InMemoryProviderCatalogReadRepository {
keys: Vec<StoredProviderCatalogKey>,
) -> Self {
Self {
routing_groups: None,
index: RwLock::new(MemoryProviderCatalogIndex {
providers: providers
.into_iter()
@@ -54,6 +57,14 @@ impl InMemoryProviderCatalogReadRepository {
}
}
pub fn with_routing_groups(
mut self,
repository: Arc<crate::repository::routing_profiles::InMemoryRoutingGroupRepository>,
) -> Self {
self.routing_groups = Some(repository);
self
}
fn snapshot(&self) -> ProviderCatalogSnapshot {
let index = self.index.read().expect("provider catalog repository lock");
ProviderCatalogSnapshot::new(
@@ -416,6 +427,46 @@ impl ProviderCatalogReadRepository for InMemoryProviderCatalogReadRepository {
#[async_trait]
impl ProviderCatalogWriteRepository for InMemoryProviderCatalogReadRepository {
async fn create_provider_in_routing_group(
&self,
provider: &StoredProviderCatalogProvider,
shift_existing_priorities_from: Option<i32>,
routing_group_id: &str,
) -> Result<StoredProviderCatalogProvider, DataLayerError> {
let groups = self.routing_groups.as_ref().ok_or_else(|| {
DataLayerError::InvalidConfiguration(
"atomic provider creation requires a shared routing group repository".to_string(),
)
})?;
groups.create_scoped_provider(routing_group_id, &provider.id, || {
let mut index = self
.index
.write()
.expect("provider catalog repository lock");
if index.providers.contains_key(&provider.id)
|| index
.providers
.values()
.any(|existing| existing.name == provider.name)
{
return Err(DataLayerError::InvalidInput(
"provider already exists".to_string(),
));
}
if let Some(target_priority) = shift_existing_priorities_from {
for existing in index.providers.values_mut() {
if existing.provider_priority >= target_priority {
existing.provider_priority += 1;
}
}
}
index
.providers
.insert(provider.id.clone(), provider.clone());
Ok(provider.clone())
})
}
async fn create_provider(
&self,
provider: &StoredProviderCatalogProvider,
@@ -1587,6 +1638,102 @@ mod tests {
.expect("key should build")
}
#[tokio::test]
async fn scoped_provider_creation_is_atomic_and_rejects_stale_group_updates() {
use crate::repository::routing_profiles::InMemoryRoutingGroupRepository;
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupRecord, RoutingGroupReadRepository, RoutingGroupWriteRepository,
UpdateRoutingGroupRecord,
};
let groups = Arc::new(InMemoryRoutingGroupRepository::default());
for id in ["selected", "other", "disabled"] {
groups
.create_routing_group(CreateRoutingGroupRecord {
id: id.into(),
name: id.into(),
description: None,
enabled: id != "disabled",
is_system_default: id == "selected",
sort_order: 0,
config_json: json!({"disabled_providers": ["already-disabled"], "rules": []}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.unwrap();
}
let repository =
InMemoryProviderCatalogReadRepository::default().with_routing_groups(groups.clone());
let provider = sample_provider("new");
assert!(repository
.create_provider_in_routing_group(&provider, Some(0), "missing")
.await
.is_err());
assert!(repository.list_providers(false).await.unwrap().is_empty());
assert!(groups
.list_routing_groups()
.await
.unwrap()
.iter()
.all(|group| group.version == 1));
repository
.create_provider_in_routing_group(&provider, None, "selected")
.await
.unwrap();
for group in groups.list_routing_groups().await.unwrap() {
assert_eq!(group.version, if group.id == "selected" { 1 } else { 2 });
assert_eq!(
group.config_json["disabled_providers"],
if group.id == "selected" {
json!(["already-disabled"])
} else {
json!(["already-disabled", "new"])
}
);
}
let before = groups.list_routing_groups().await.unwrap();
assert!(repository
.create_provider_in_routing_group(&provider, Some(0), "other")
.await
.is_err());
assert_eq!(groups.list_routing_groups().await.unwrap(), before);
let stale = groups
.update_routing_group(
"other",
UpdateRoutingGroupRecord {
expected_version: Some(1),
config_json: Some(json!({"disabled_providers": []})),
..Default::default()
},
)
.await;
assert!(
matches!(stale, Err(DataLayerError::InvalidInput(message)) if message == "routing_group_version_conflict")
);
assert_eq!(groups.list_routing_groups().await.unwrap(), before);
let updated = groups
.update_routing_group(
"other",
UpdateRoutingGroupRecord {
expected_version: Some(2),
config_json: Some(json!({"disabled_providers": ["already-disabled"]})),
version: Some(2),
..Default::default()
},
)
.await
.unwrap()
.unwrap();
assert_eq!(updated.version, 3);
assert_eq!(
updated.config_json["disabled_providers"],
json!(["already-disabled"])
);
}
#[tokio::test]
async fn reads_provider_catalog_items_by_id() {
let repository = InMemoryProviderCatalogReadRepository::seed(
@@ -20,6 +20,58 @@ pub struct InMemoryRoutingGroupRepository {
}
impl InMemoryRoutingGroupRepository {
pub(crate) fn create_scoped_provider<T>(
&self,
selected_group_id: &str,
provider_id: &str,
create: impl FnOnce() -> Result<T, DataLayerError>,
) -> Result<T, DataLayerError> {
let mut groups = self.groups.write().expect("routing group repository lock");
if !groups.contains_key(selected_group_id) {
return Err(DataLayerError::InvalidInput(
"routing_group_not_found".to_string(),
));
}
let mut updated = groups.clone();
for group in updated.values_mut() {
let had_provider = group
.config_json
.get("disabled_providers")
.and_then(serde_json::Value::as_array)
.is_some_and(|disabled| {
disabled
.iter()
.any(|value| value.as_str() == Some(provider_id))
});
if (group.id == selected_group_id && !had_provider)
|| (group.id != selected_group_id && had_provider)
{
continue;
}
let object = group.config_json.as_object_mut().ok_or_else(|| {
DataLayerError::InvalidInput("routing group config must be an object".to_string())
})?;
let disabled = object
.entry("disabled_providers")
.or_insert_with(|| serde_json::json!([]));
let disabled = disabled.as_array_mut().ok_or_else(|| {
DataLayerError::InvalidInput("disabled_providers must be an array".to_string())
})?;
disabled.retain(|value| value.as_str() != Some(provider_id));
if group.id != selected_group_id {
disabled.push(serde_json::json!(provider_id));
}
group.version = group.version.saturating_add(1);
group.updated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
}
let created = create()?;
*groups = updated;
Ok(created)
}
pub fn seed<I, B, V>(groups: I, bindings: B, versions: V) -> Self
where
I: IntoIterator<Item = StoredRoutingGroup>,
+4
View File
@@ -224,6 +224,10 @@ pub struct RoutingRule {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingGroupConfig {
/// Providers excluded from every model in this group, including providers
/// otherwise selected by model policies or routing rules.
#[serde(default)]
pub disabled_providers: Vec<String>,
/// The default policy is global for the selected strategy group. Model
/// differences are expressed through `model_policies` and `rules`.
#[serde(default)]
+175 -10
View File
@@ -85,12 +85,17 @@ pub fn resolve_routing_policy(
selection_source: input.selection_source.to_string(),
requested_model: input.requested_model.to_string(),
resolved_model: input.resolved_model.to_string(),
priority_mode: config.default_policy.priority_mode,
// Legacy global_key values remain readable, but routing groups now
// always rank providers before their keys.
priority_mode: RoutingSetPriorityMode::Provider,
scheduling_mode: config.default_policy.scheduling_mode,
keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion,
sticky_key_attempts: config.default_policy.sticky_key_attempts,
execution_policy: config.default_policy.execution_policy.clone(),
ranking_overlay: RankingOverlay::default(),
ranking_overlay: RankingOverlay {
disabled_providers: config.disabled_providers.clone(),
..RankingOverlay::default()
},
mutation_plan: MutationPlan::default(),
pool_policy_overrides: BTreeMap::new(),
matched_rules: Vec::new(),
@@ -203,14 +208,13 @@ fn apply_action(
policy.ranking_overlay.allowed_keys = key_ids.clone();
}
RoutingAction::SetScheduling {
priority_mode,
// Keep accepting the legacy field without re-enabling key-first
// scheduling through a model rule.
priority_mode: _,
scheduling_mode,
keep_priority_on_conversion,
sticky_key_attempts,
} => {
if let Some(priority_mode) = priority_mode {
policy.priority_mode = *priority_mode;
}
if let Some(scheduling_mode) = scheduling_mode {
policy.scheduling_mode = *scheduling_mode;
}
@@ -316,6 +320,164 @@ mod tests {
use super::*;
#[test]
fn legacy_key_scheduling_keeps_overrides_but_resolves_to_provider_ordering() {
let config: RoutingGroupConfig = serde_json::from_value(json!({
"default_policy": { "priority_mode": "global_key" },
"model_policies": [{
"model": "*",
"provider_priority_overrides": { "provider-a": 7 },
"key_priority_overrides": { "key-a": 2 },
"key_priority_overrides_by_format": { "openai:chat": { "key-a": 3 } },
"pool_priority_overrides": { "provider-pool": 4 }
}],
"rules": [{
"id": "legacy-key-client", "phase": "client_request",
"actions": [{ "type": "set_scheduling", "priority_mode": "global_key", "scheduling_mode": "fixed_order" }]
}]
}))
.expect("legacy key scheduling must stay readable");
let stored = serde_json::to_value(&config).unwrap();
assert_eq!(stored["default_policy"]["priority_mode"], "global_key");
assert_eq!(
stored["rules"][0]["actions"][0]["priority_mode"],
"global_key"
);
assert_eq!(
serde_json::from_value::<RoutingGroupConfig>(stored).unwrap(),
config
);
for phase in [
RoutingRulePhase::ClientRequest,
RoutingRulePhase::ProviderRequest,
] {
let policy = resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: Some("legacy-group"),
group_version: Some(1),
selection_source: "explicit",
requested_model: "model-a",
resolved_model: "model-a",
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase,
},
)
.unwrap();
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::Provider);
assert_eq!(
policy.scheduling_mode,
if phase == RoutingRulePhase::ClientRequest {
RoutingSchedulingMode::FixedOrder
} else {
RoutingSchedulingMode::CacheAffinity
}
);
assert_eq!(
policy.matched_rules.len(),
usize::from(phase == RoutingRulePhase::ClientRequest)
);
assert_eq!(
policy.ranking_overlay.provider_priority_overrides["provider-a"],
7
);
assert_eq!(policy.ranking_overlay.key_priority_overrides["key-a"], 2);
assert_eq!(
policy.ranking_overlay.pool_priority_overrides["provider-pool"],
4
);
assert_eq!(
policy
.ranking_overlay
.key_priority_for_format("key-a", "openai:chat", 99),
3
);
}
assert_eq!(
config.default_policy.priority_mode,
RoutingSetPriorityMode::GlobalKey
);
}
#[test]
fn group_disabled_providers_apply_to_every_model_and_cannot_be_reenabled() {
let config: RoutingGroupConfig = serde_json::from_value(json!({
"disabled_providers": ["provider-disabled"],
"model_policies": [{
"model": "model-allowlist",
"allowed_providers": ["provider-disabled", "provider-enabled"]
}],
"rules": [{
"id": "replace-provider-allowlist",
"conditions": { "field": "model", "op": "eq", "value": "rule-allowlist" },
"actions": [{
"type": "restrict_providers",
"provider_ids": ["provider-disabled", "provider-enabled"]
}, {
"type": "set_provider_priority",
"provider_id": "provider-disabled",
"priority": 0
}]
}, {
"id": "clear-provider-allowlist",
"conditions": { "field": "model", "op": "eq", "value": "rule-unrestricted" },
"actions": [{ "type": "restrict_providers", "provider_ids": [] }]
}]
}))
.expect("group provider exclusions should deserialize");
// The field survives the same round trip used when persisting or
// publishing strategy configuration.
let stored_config = serde_json::to_value(&config).unwrap();
assert_eq!(
stored_config["disabled_providers"],
json!(["provider-disabled"])
);
let config: RoutingGroupConfig = serde_json::from_value(stored_config).unwrap();
for model in [
"future-model",
"model-allowlist",
"rule-allowlist",
"rule-unrestricted",
] {
let policy = resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
selection_source: "explicit",
requested_model: model,
resolved_model: model,
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
},
)
.expect("policy with group provider exclusions should resolve");
assert!(
!policy.ranking_overlay.provider_allowed("provider-disabled"),
"{model} must retain the group exclusion"
);
assert!(policy.ranking_overlay.provider_allowed("provider-enabled"));
let has_allowlist = matches!(model, "model-allowlist" | "rule-allowlist");
assert_eq!(
policy.ranking_overlay.provider_allowed("provider-unlisted"),
!has_allowlist,
"{model} should preserve its normal allowlist behavior"
);
}
}
#[test]
fn all_model_scheduling_and_rankings_apply_to_future_models() {
let config: RoutingGroupConfig = serde_json::from_value(json!({
@@ -330,6 +492,7 @@ mod tests {
"rules": []
}))
.expect("all-model scheduling config should deserialize");
assert!(config.disabled_providers.is_empty());
for model in ["existing-model", "future-model"] {
let policy = resolve_routing_policy(
@@ -349,7 +512,7 @@ mod tests {
},
)
.expect("all-model scheduling policy should resolve");
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::GlobalKey);
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::Provider);
assert_eq!(policy.scheduling_mode, RoutingSchedulingMode::LoadBalance);
assert_eq!(
policy
@@ -419,7 +582,7 @@ mod tests {
.is_empty());
assert!(policy.matched_rules.is_empty());
} else {
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::GlobalKey);
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::Provider);
assert_eq!(policy.scheduling_mode, RoutingSchedulingMode::FixedOrder);
assert_eq!(
policy
@@ -436,6 +599,7 @@ mod tests {
#[test]
fn resolves_model_policy_and_matching_rule() {
let config = RoutingGroupConfig {
disabled_providers: vec![],
default_policy: RoutingDefaultPolicy::default(),
model_policies: vec![RoutingModelPolicy {
model: "gpt-5".to_string(),
@@ -504,6 +668,7 @@ mod tests {
#[test]
fn default_policy_applies_to_models_without_an_override() {
let config = RoutingGroupConfig {
disabled_providers: vec![],
default_policy: RoutingDefaultPolicy {
priority_mode: RoutingSetPriorityMode::GlobalKey,
scheduling_mode: RoutingSchedulingMode::LoadBalance,
@@ -538,7 +703,7 @@ mod tests {
)
.expect("the specially configured model should resolve");
assert_eq!(special.priority_mode, RoutingSetPriorityMode::GlobalKey);
assert_eq!(special.priority_mode, RoutingSetPriorityMode::Provider);
assert_eq!(special.scheduling_mode, RoutingSchedulingMode::LoadBalance);
assert!(special.keep_priority_on_conversion);
assert_eq!(special.sticky_key_attempts, 3);
@@ -572,7 +737,7 @@ mod tests {
)
.expect("an unconfigured model should keep using the default policy");
assert_eq!(ordinary.priority_mode, RoutingSetPriorityMode::GlobalKey);
assert_eq!(ordinary.priority_mode, RoutingSetPriorityMode::Provider);
assert_eq!(ordinary.scheduling_mode, RoutingSchedulingMode::LoadBalance);
assert!(ordinary.keep_priority_on_conversion);
assert_eq!(ordinary.sticky_key_attempts, 3);
+47 -5
View File
@@ -13,6 +13,9 @@ pub enum CandidateKind {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RankingOverlay {
/// Group-wide exclusions take precedence over every provider allowlist.
#[serde(default)]
pub disabled_providers: Vec<String>,
#[serde(default)]
pub allowed_providers: Vec<String>,
#[serde(default)]
@@ -112,11 +115,15 @@ impl RankingOverlay {
}
pub fn provider_allowed(&self, provider_id: &str) -> bool {
self.allowed_providers.is_empty()
|| self
.allowed_providers
.iter()
.any(|item| item == provider_id)
!self
.disabled_providers
.iter()
.any(|item| item == provider_id)
&& (self.allowed_providers.is_empty()
|| self
.allowed_providers
.iter()
.any(|item| item == provider_id))
}
pub fn key_allowed(&self, key_id: &str) -> bool {
@@ -180,6 +187,41 @@ mod tests {
use super::*;
#[test]
fn disabled_providers_take_precedence_over_allowlists() {
let mut overlay = RankingOverlay {
disabled_providers: vec!["provider-disabled".to_string()],
..RankingOverlay::default()
};
assert!(!overlay.provider_allowed("provider-disabled"));
assert!(overlay.provider_allowed("provider-enabled"));
overlay.allowed_providers = vec![
"provider-disabled".to_string(),
"provider-enabled".to_string(),
];
assert!(!overlay.provider_allowed("provider-disabled"));
assert!(overlay.provider_allowed("provider-enabled"));
assert!(!overlay.provider_allowed("provider-unlisted"));
// An allowlist containing only disabled providers must not become an
// empty allowlist, which would otherwise allow unrelated providers.
overlay.allowed_providers = vec!["provider-disabled".to_string()];
assert!(!overlay.provider_allowed("provider-disabled"));
assert!(!overlay.provider_allowed("provider-enabled"));
}
#[test]
fn legacy_overlay_without_disabled_providers_preserves_provider_selection() {
let overlay: RankingOverlay = serde_json::from_value(serde_json::json!({
"allowed_providers": ["provider-enabled"]
}))
.expect("legacy overlays should remain readable");
assert!(overlay.disabled_providers.is_empty());
assert!(overlay.provider_allowed("provider-enabled"));
assert!(!overlay.provider_allowed("provider-unlisted"));
}
#[test]
fn overlay_applies_provider_and_key_priority() {
let overlay = RankingOverlay {
+1
View File
@@ -159,6 +159,7 @@ export async function updateProvider(
*/
export async function createProvider(
data: {
routing_group_id?: string
name: string
provider_type?: ProviderType
description?: string
+1
View File
@@ -67,6 +67,7 @@ export interface RoutingGroupCreateRequest {
}
export interface RoutingGroupUpdateRequest {
expected_version?: number
name?: string
description?: string | null
enabled?: boolean
+27 -1
View File
@@ -1,17 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CircleHelp } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{
label: string
text: string
portal?: boolean
}>()
const open = ref(false)
</script>
<template>
<span class="group relative inline-flex">
<TooltipProvider v-if="props.portal">
<Tooltip v-model:open="open">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex shrink-0 items-center justify-center rounded-sm p-0.5 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="`${props.label}说明`"
:aria-expanded="open"
@click.stop.prevent="open = !open"
>
<CircleHelp class="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
class="max-w-xs whitespace-pre-line text-xs leading-5"
>
{{ props.text }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span
v-else
class="group relative inline-flex"
>
<button
type="button"
class="inline-flex items-center justify-center rounded-sm p-0.5 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -222,8 +222,8 @@ const { confirm } = useConfirm()
const { success, warning, error: showError } = useToast()
const actionOptions: ProviderBatchActionOption[] = [
{ value: 'enable', label: '启用', hint: '恢复所选提供商参与调度。', icon: Power },
{ value: 'disable', label: '停用', hint: '停止所选提供商参与调度,保留配置。', icon: PowerOff },
{ value: 'enable', label: '启用', hint: '全局启用所选提供商;各策略分组的启用设置仍分别生效。', icon: Power },
{ value: 'disable', label: '停用', hint: '在所有策略分组中停止所选提供商参与调度,保留配置。', icon: PowerOff },
{ value: 'delete', label: '删除', hint: '永久删除所选提供商及其端点、账号和配置,此操作不可恢复。', icon: Trash2, destructive: true },
]
@@ -242,7 +242,7 @@ const progressTotal = ref(0)
const progressLabel = ref('')
const lastResultMessage = ref('')
const dialogDescription = computed(() => '批量启用、停用或删除当前页提供商')
const dialogDescription = computed(() => '批量修改当前页提供商的全局状态,或删除提供商')
const selectedIdSet = computed(() => new Set(selectedProviderIds.value))
const selectedCount = computed(() => selectedProviderIds.value.length)
const selectedActionLabel = computed(() => actionOptions.find(action => action.value === selectedAction.value)?.label || '')
@@ -84,10 +84,17 @@
:variant="provider.is_active ? 'success' : 'secondary'"
class="shrink-0 text-xs"
>
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
{{ legacyT(provider.is_active ? '全局启用' : '全局停用') }}
</Badge>
</div>
<div
v-if="$slots.scheduling"
class="shrink-0 px-4 pb-3"
>
<slot name="scheduling" />
</div>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain break-words px-4 pb-4">
<div class="space-y-2 rounded-xl border border-border/40 bg-muted/20 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
@@ -179,16 +186,19 @@
class="flex shrink-0 items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-primary"
:title="legacyT('查看详情')"
:aria-label="legacyT('查看详情')"
@click="$emit('viewDetail', provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<div class="flex shrink-0 items-center gap-0.5">
<slot name="group-action" />
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-primary"
:title="legacyT('查看详情')"
:aria-label="legacyT('查看详情')"
@click="$emit('viewDetail', provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
@@ -214,8 +224,8 @@
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
:title="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
:aria-label="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
:title="legacyT(provider.is_active ? '全局停用提供商' : '全局启用提供商')"
:aria-label="legacyT(provider.is_active ? '全局停用提供商' : '全局启用提供商')"
@click="$emit('toggleStatus', provider)"
>
<Power class="h-3.5 w-3.5" />
@@ -1228,6 +1228,15 @@ function applyProviderSnapshot(updated: ProviderWithEndpointsSummary): void {
provider.value = updated
}
// A provider opened from scheduling may not belong to the resource page's loaded slice.
// Apply edits supplied by the parent even when the drawer loaded its own initial snapshot.
watch(() => props.initialProvider, (updated) => {
if (!props.open || !updated || updated.id !== props.providerId) return
providerLoadRequestId += 1
applyProviderSnapshot(updated)
loading.value = false
})
function getProviderKeysPageSize(providerType?: string | null): number {
return (providerType || '').trim().toLowerCase() === 'custom'
? CUSTOM_PROVIDER_KEYS_PAGE_SIZE
@@ -4,8 +4,8 @@
size="icon"
class="h-8 w-5 touch-none select-none cursor-grab text-muted-foreground/50 hover:text-primary active:cursor-grabbing disabled:cursor-default"
:disabled="disabled"
:title="legacyT('拖动调整当前页展示顺序,也可使用方向键移动')"
:aria-label="`${legacyT('调整展示顺序')}: ${providerName}`"
:title="legacyT('拖动调整当前分组调度优先级,也可使用方向键移动')"
:aria-label="`${legacyT('调整调度优先级')}: ${providerName}`"
data-provider-drag-handle
@pointerdown.stop="$emit('pointerdown', $event)"
@keydown.stop="$emit('keydown', $event)"
@@ -1,8 +1,14 @@
<template>
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-2 text-muted-foreground">
{{ legacyT(hasActiveFilters ? '未找到匹配当前筛选条件的提供商' : '暂无提供商,点击右上角添加') }}
{{ legacyT(hasActiveFilters ? '未找到匹配当前筛选条件的提供商' : title || '暂无提供商,点击右上角添加') }}
</div>
<p
v-if="!hasActiveFilters && description"
class="px-4 text-xs text-muted-foreground"
>
{{ legacyT(description) }}
</p>
<Button
v-if="hasActiveFilters"
variant="outline"
@@ -20,6 +26,8 @@ import { useI18n } from '@/i18n'
defineProps<{
hasActiveFilters: boolean
title?: string
description?: string
}>()
defineEmits<{
@@ -11,6 +11,12 @@
class="space-y-5"
@submit.prevent="handleSubmit"
>
<p
v-if="!isEditMode && routingGroupId"
class="rounded-lg bg-muted/40 px-3 py-2 text-sm text-muted-foreground"
>
新提供商仅在“{{ routingGroupName || '当前策略分组' }}”中启用,在其他策略分组中默认禁用。
</p>
<!-- 基本信息 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
@@ -359,6 +365,8 @@ import { parseNumberInput } from '@/utils/form'
const props = defineProps<{
modelValue: boolean
provider?: ProviderWithEndpointsSummary | null // 编辑模式时传入
routingGroupId?: string
routingGroupName?: string
maxPriority?: number // 当前已有的最大优先级值
}>()
@@ -550,8 +558,8 @@ const handleSubmit = async () => {
emit('providerUpdated', updated)
} else {
// 创建提供商(优先级由后端自动置顶)
await createProvider(basePayload)
success(legacyT('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序'), legacyT('创建成功'))
await createProvider({ ...basePayload, routing_group_id: props.routingGroupId })
success(legacyT('提供商已创建,可添加端点和密钥,并在目录中调整调度顺序'), legacyT('创建成功'))
emit('providerCreated')
}
@@ -0,0 +1,39 @@
<template>
<div
class="flex items-center text-xs"
:class="showPriority ? 'flex-wrap gap-x-4 gap-y-2 rounded-lg bg-muted/30 px-2.5 py-2' : 'justify-center'"
@click.stop
@mousedown.stop
@pointerdown.stop
>
<label
v-if="showPriority !== false"
class="flex items-center gap-2"
>
<span class="text-muted-foreground">优先级</span>
<ProviderPriorityInput
:provider-name="providerName"
:priority="priority"
:edit-context="editContext"
:disabled="disabled || priorityDisabled"
@update:priority="emit('update:priority', $event)"
/>
</label>
<Badge
:variant="enabled ? 'success' : 'secondary'"
class="whitespace-nowrap text-xs"
>
{{ legacyT(enabled ? '本组启用' : '本组禁用') }}
</Badge>
</div>
</template>
<script setup lang="ts">
import Badge from '@/components/ui/badge.vue'
import { useI18n } from '@/i18n'
import ProviderPriorityInput from './ProviderPriorityInput.vue'
withDefaults(defineProps<{ providerName: string; priority: number; enabled: boolean; disabled?: boolean; priorityDisabled?: boolean; showPriority?: boolean; editContext?: string }>(), { showPriority: true, editContext: undefined })
const emit = defineEmits<{ 'update:priority': [value: number] }>()
const { legacyT } = useI18n()
</script>
@@ -0,0 +1,31 @@
<template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:class="enabled ? 'text-primary hover:text-primary' : 'text-muted-foreground/70 hover:text-foreground'"
:disabled="disabled"
:title="legacyT(enabled ? '本组禁用提供商' : '本组启用提供商')"
:aria-label="`${providerName} ${legacyT('本组启用')}`"
:aria-pressed="enabled"
@click.stop="emit('update:enabled', !enabled)"
@mousedown.stop
@pointerdown.stop
>
<component
:is="enabled ? ToggleRight : ToggleLeft"
class="h-4 w-4"
aria-hidden="true"
/>
</Button>
</template>
<script setup lang="ts">
import { ToggleLeft, ToggleRight } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import { useI18n } from '@/i18n'
defineProps<{ providerName: string; enabled: boolean; disabled?: boolean }>()
const emit = defineEmits<{ 'update:enabled': [value: boolean] }>()
const { legacyT } = useI18n()
</script>
@@ -3,6 +3,7 @@
class="p-4 space-y-3 hover:bg-muted/20 transition-colors cursor-pointer"
@click="$emit('viewDetail', provider.id)"
>
<slot name="scheduling" />
<!-- 第一行:名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<slot name="drag-handle" />
@@ -24,7 +25,7 @@
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs shrink-0"
>
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
{{ legacyT(provider.is_active ? '全局启用' : '全局停用') }}
</Badge>
</div>
<!-- 内联编辑备注 (移动端) -->
@@ -72,6 +73,7 @@
class="flex items-center gap-0.5 shrink-0"
@click.stop
>
<slot name="group-action" />
<Button
variant="ghost"
size="icon"
@@ -85,7 +87,7 @@
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('编辑')"
:title="legacyT('编辑提供商')"
@click="$emit('editProvider', provider)"
>
<Edit class="h-3.5 w-3.5" />
@@ -103,6 +105,8 @@
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT(provider.is_active ? '全局停用提供商' : '全局启用提供商')"
:aria-label="legacyT(provider.is_active ? '全局停用提供商' : '全局启用提供商')"
@click="$emit('toggleStatus', provider)"
>
<Power class="h-3.5 w-3.5" />
@@ -0,0 +1,78 @@
<template>
<span
class="inline-flex min-w-6 justify-center"
@click.stop
@mousedown.stop
@pointerdown.stop
@keydown.stop
>
<input
v-if="editing"
ref="inputRef"
v-model="draft"
type="number"
min="0"
max="2147483647"
step="1"
:disabled="disabled"
:aria-label="`${providerName} 的组内优先级`"
class="h-7 w-14 rounded border border-input bg-background px-1 text-center text-xs tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@blur="finishEdit(false)"
@keydown.enter.prevent="finishEdit(true)"
@keydown.esc.prevent="cancelEdit(true)"
>
<button
v-else
ref="buttonRef"
type="button"
:disabled="disabled"
:aria-label="`${providerName} 的组内优先级`"
title="点击修改优先级"
class="min-h-7 min-w-6 rounded px-1 text-xs font-medium tabular-nums text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50"
@click="startEdit"
>
{{ priority }}
</button>
</span>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
const props = defineProps<{ providerName: string; priority: number; disabled?: boolean; editContext?: string }>()
const emit = defineEmits<{ 'update:priority': [value: number] }>()
const editing = ref(false)
const draft = ref<string | number>('')
const inputRef = ref<HTMLInputElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null)
async function startEdit() {
if (props.disabled) return
draft.value = String(props.priority)
editing.value = true
await nextTick()
inputRef.value?.focus()
inputRef.value?.select()
}
async function cancelEdit(restoreFocus = false) {
editing.value = false
draft.value = String(props.priority)
if (restoreFocus) {
await nextTick()
buttonRef.value?.focus({ preventScroll: true })
}
}
function finishEdit(restoreFocus: boolean) {
if (!editing.value) return
const value = String(draft.value).trim() === '' ? NaN : Number(draft.value)
const valid = !props.disabled && Number.isInteger(value) && value >= 0 && value <= 2147483647
void cancelEdit(restoreFocus)
if (valid && value !== props.priority) {
emit('update:priority', value)
}
}
watch(() => [props.priority, props.disabled, props.editContext], () => { void cancelEdit() }, { flush: 'sync' })
</script>
@@ -0,0 +1,30 @@
<template>
<span
v-if="provider"
class="text-xs tabular-nums text-muted-foreground"
:title="error?.message"
>
<template v-if="balanceLoading">余额查询中</template>
<template v-else-if="balance">余额 {{ balances?.formatBalanceDisplay(balance) }}</template>
<template v-else-if="error">余额暂不可用</template>
<template v-else-if="provider.billing_type === 'monthly_quota'">
月度已用 ${{ (provider.monthly_used_usd ?? 0).toFixed(2) }} / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
</template>
<template v-else-if="provider.ops_configured">余额 —</template>
</span>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { useSchedulingProviderBalance } from '../composables/useSchedulingProviderBalance'
const props = defineProps<{ provider?: ProviderWithEndpointsSummary }>()
const balances = useSchedulingProviderBalance()
const balance = computed(() => props.provider ? balances?.getProviderBalance(props.provider.id) : null)
const error = computed(() => props.provider ? balances?.getProviderBalanceError(props.provider.id) : null)
const balanceLoading = computed(() => props.provider ? balances?.isBalanceLoading(props.provider.id) : false)
watch(() => [props.provider?.id, props.provider?.ops_configured], () => {
if (props.provider) balances?.register(props.provider)
}, { immediate: true })
</script>
@@ -0,0 +1,808 @@
<template>
<section
class="min-w-0"
aria-label="提供商调度工作区"
>
<div class="grid min-w-0 items-stretch gap-4 xl:grid-cols-[400px_minmax(0,1fr)]">
<aside
class="flex min-w-0 flex-col gap-3 xl:min-h-0 xl:[contain:size]"
aria-label="策略分组设置"
>
<Card
class="flex min-w-0 flex-1 flex-col xl:h-0 xl:min-h-full"
aria-label="策略分组与调度配置"
>
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-border/50 px-3 py-3 sm:py-3.5">
<h3 class="sr-only">
策略分组
</h3>
<Select
:model-value="selectedValue"
:disabled="busy"
@update:model-value="changeGroup"
>
<SelectTrigger
class="h-8 min-w-0 flex-1 px-3 text-sm"
aria-label="当前调度策略"
>
<SelectValue :placeholder="loading ? '正在加载分组' : '选择策略分组'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="group in groups"
:key="group.id"
:value="group.id"
>
{{ group.name }}{{ group.is_system_default ? ' · 默认' : '' }}{{ !group.enabled ? ' · 停用' : '' }}
</SelectItem>
</SelectContent>
</Select>
<div class="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="busy"
title="新建分组"
aria-label="新建策略"
@click="openCreate"
>
<Plus class="h-3.5 w-3.5" />
</Button>
<Button
v-if="draft?.id"
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive"
:disabled="busy"
title="删除策略分组"
aria-label="删除策略"
@click="deleteDialogOpen = true"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
<Button
v-if="draft"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="{ 'text-primary': draft.is_system_default }"
:disabled="busy"
:title="draft.is_system_default ? '取消系统默认(保存后生效)' : '设为系统默认(保存后生效)'"
aria-label="设为系统默认"
:aria-pressed="draft.is_system_default"
@click="draft.is_system_default = !draft.is_system_default"
>
<Star
class="h-3.5 w-3.5"
:class="{ 'fill-current': draft.is_system_default }"
/>
</Button>
<Button
v-if="draft"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="{ 'text-primary': draftDirty }"
:disabled="!canSaveDraft"
:title="saving ? '正在保存…' : !routingSchedulingValid ? '请先选择适用模型' : draftDirty ? '保存修改' : '已保存'"
aria-label="保存调度"
:aria-busy="saving"
@click="saveDraft"
>
<Save
class="h-3.5 w-3.5"
:class="{ 'animate-pulse': saving }"
/>
</Button>
</div>
</div>
<div class="min-h-0 flex-1 xl:overflow-y-auto">
<div
v-if="draft"
ref="groupMetadata"
class="min-w-0 space-y-2 border-b border-border/50 p-3"
aria-label="分组信息"
:inert="busy"
>
<div class="flex min-w-0 items-center gap-2">
<label class="min-w-0 flex-1">
<span class="sr-only">策略名称</span>
<Input
v-model="draft.name"
size="sm"
class="min-w-0"
aria-label="策略名称"
placeholder="分组名称"
:disabled="busy"
/>
</label>
<label class="flex shrink-0 items-center gap-1 text-xs">
<span>启用</span>
<Switch
v-model="draft.enabled"
:disabled="busy"
aria-label="启用策略"
/>
</label>
</div>
<label class="block min-w-0">
<span class="sr-only">描述</span>
<Input
v-model="draft.description"
size="sm"
aria-label="策略描述"
placeholder="描述这个分组的用途"
:disabled="busy"
/>
</label>
</div>
<RoutingSchedulingPolicyEditor
v-if="draft"
:key="draftGeneration"
ref="routingSchedulingPolicyEditor"
:config="draft.config_json"
:initial-selection="initialSchedulingSelection ?? undefined"
:refresh-revision="providerRevision"
:global-models="globalModels"
:loading-models="loadingGlobalModels"
:models-error="globalModelsError"
:disabled="busy"
:aria-busy="saving"
class="p-3"
aria-label="策略调度配置"
layout="config-only"
sidebar
@update:config="updateDraftConfig"
@validity-change="routingSchedulingValid = $event"
@selection-change="activePolicy = $event"
@reload-models="loadGlobalModels()"
@inspect-provider="emit('inspect-provider', $event)"
/>
<div
v-if="draft"
class="min-w-0"
:inert="busy"
>
<section
class="min-w-0 border-t border-border/50"
:inert="busy"
>
<button
type="button"
class="flex w-full items-center justify-between gap-3 px-3 py-3 text-left"
:aria-expanded="advancedOpen"
aria-label="高级设置"
@click="advancedOpen = !advancedOpen"
>
<span class="text-sm font-medium">高级设置</span>
<ChevronRight
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
:class="{ 'rotate-90': advancedOpen }"
/>
</button>
<div
v-show="advancedOpen"
class="min-w-0 space-y-4 px-3 pb-3"
>
<section class="min-w-0 space-y-2">
<div>
<h3 class="text-sm font-medium">
系统配置
</h3>
</div>
<div class="grid min-w-0 grid-cols-1 gap-2">
<div
class="order-1 flex min-w-0 min-h-10 items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-2 text-xs"
data-testid="keep-priority-on-conversion"
>
<div class="flex min-w-0 items-center gap-1.5">
<span class="font-medium">格式转换保持优先级</span>
<HelpHint
portal
label="格式转换保持优先级"
text="开启后,跨 API 格式转换的候选不会被降级到同格式候选之后;Provider 自身的同名开关仍单独生效。"
/>
</div>
<Switch
:model-value="keepPriorityOnConversion"
:disabled="saving"
aria-label="格式转换保持优先级"
@update:model-value="updateKeepPriorityOnConversion"
/>
</div>
<div
class="order-3 flex min-w-0 min-h-10 items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-2 text-xs"
data-testid="cf-heartbeat"
>
<div class="flex min-w-0 items-center gap-1.5">
<span class="font-medium">CF保持心跳</span>
<HelpHint
portal
label="CF保持心跳"
text="同步生图和标准文本非流式失败时保持外层 HTTP 状态为 200,并在响应体中返回错误。"
/>
</div>
<Switch
:model-value="cfHeartbeat"
:disabled="saving"
aria-label="CF保持心跳"
@update:model-value="updateExecutionPolicy('enable_cf_heartbeat', $event)"
/>
</div>
<div
class="order-2 flex min-w-0 min-h-10 items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-2 text-xs"
data-testid="cyber-continue-failover"
>
<div class="flex min-w-0 items-center gap-1.5">
<span class="font-medium">Cyber继续转移</span>
<HelpHint
portal
label="Cyber继续转移"
text="响应开始前遇到 Cyber Policy 错误时继续故障转移。"
/>
</div>
<Switch
:model-value="cyberContinueFailover"
:disabled="saving"
aria-label="Cyber继续转移"
@update:model-value="updateExecutionPolicy('cyber_continue_failover', $event)"
/>
</div>
<div
class="order-5 flex min-w-0 min-h-10 items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-2 text-xs"
data-testid="cancel-on-client-disconnect"
>
<div class="flex min-w-0 items-center gap-1.5">
<span class="font-medium">取消请求立即打断</span>
<HelpHint
portal
label="取消请求立即打断"
text="默认关闭:客户端取消或断开后,服务端继续等待请求完成并正常计费。开启后立即打断且不计费,按次计费的请求仍收取单次请求费用。仅作用于当前调度策略。"
/>
</div>
<Switch
:model-value="cancelOnClientDisconnect"
:disabled="saving"
aria-label="取消请求立即打断"
@update:model-value="updateExecutionPolicy('cancel_on_client_disconnect', $event)"
/>
</div>
</div>
</section>
</div>
</section>
<section class="min-w-0 border-t border-border/50">
<button
type="button"
class="flex w-full items-center justify-between gap-3 px-3 py-3 text-left"
:aria-expanded="failoverOpen"
aria-label="故障转移"
@click="failoverOpen = !failoverOpen"
>
<span class="text-sm font-medium">故障转移</span>
<ChevronRight
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
:class="{ 'rotate-90': failoverOpen }"
/>
</button>
<div
v-show="failoverOpen"
class="min-w-0 px-3 pb-3"
>
<RoutingFailoverPolicyEditor
:key="draftGeneration"
ref="routingFailoverPolicyEditor"
:model-value="draft.config_json.default_policy"
:disabled="saving"
sidebar
@update:model-value="updateRoutingFailoverPolicy"
@pending-change="routingFailoverPending = $event"
>
<template #limits-extra>
<label
class="min-w-0 space-y-1.5 text-xs"
data-testid="sticky-key-attempts"
>
<span>错误重试次数</span>
<Input
:model-value="stickyKeyAttempts"
type="number"
min="0"
max="99"
:disabled="saving"
aria-label="错误重试次数"
@update:model-value="updateStickyKeyAttempts"
/>
</label>
</template>
</RoutingFailoverPolicyEditor>
</div>
</section>
</div>
<div
v-if="saveConflict"
role="alert"
class="m-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm"
>
<span>分组已被其他操作更新,当前修改已保留。重新加载后可基于最新配置继续编辑。</span>
<Button
variant="outline"
size="sm"
:disabled="busy"
aria-label="重新加载分组"
@click="reloadCurrentGroup"
>
重新加载分组
</Button>
</div>
<div
v-if="!draft"
class="p-8 text-center"
>
<p class="text-sm text-muted-foreground">
{{ emptyMessage }}
</p>
<Button
v-if="!loading"
variant="outline"
class="mt-4"
@click="loadingError ? refreshGroups() : groups.length ? openGroup(defaultGroupId) : openCreate()"
>
{{ loadingError ? '重新加载' : groups.length ? '打开默认分组' : '新建策略分组' }}
</Button>
</div>
</div>
</Card>
</aside>
<div
class="min-w-0"
aria-label="提供商目录"
>
<slot />
</div>
</div>
<Dialog
:model-value="createDialogOpen"
title="新建策略分组"
description="创建独立分组,创建成功后切换到新分组。"
size="md"
:persistent="busy"
@update:model-value="closeCreate"
>
<div
class="space-y-4"
:inert="busy"
>
<label class="block space-y-1.5 text-sm"><span>分组名称</span><Input
v-model="createForm.name"
aria-label="新分组名称"
placeholder="例如:日常使用"
/></label>
<label class="block space-y-1.5 text-sm"><span>描述</span><Input
v-model="createForm.description"
aria-label="新分组描述"
placeholder="这个分组的用途"
/></label>
<label class="flex items-center justify-between gap-3 text-sm"><span>启用分组</span><Switch
v-model="createForm.enabled"
aria-label="启用新分组"
/></label>
<p class="text-xs text-muted-foreground">
使用默认调度配置;创建后可在提供商目录中调整成员和顺序。
</p>
</div>
<template #footer>
<Button
:disabled="busy || !createForm.name.trim()"
aria-label="创建策略分组"
@click="createGroup"
>
{{ creating ? '创建中…' : '创建分组' }}
</Button>
<Button
variant="outline"
:disabled="busy"
aria-label="取消新建分组"
@click="closeCreate(false)"
>
取消
</Button>
</template>
</Dialog>
<AlertDialog
v-model="deleteDialogOpen"
type="destructive"
title="删除调度策略"
:description="`确认删除调度策略「${draft?.name ?? ''}」?此操作无法撤销。`"
confirm-text="删除"
:loading="deleting"
@confirm="confirmDeleteDraft"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter, type RouteLocationNormalized } from 'vue-router'
import { ChevronRight, Plus, Save, Star, Trash2 } from 'lucide-vue-next'
import { Button, Card, Dialog, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch } from '@/components/ui'
import { AlertDialog } from '@/components/common'
import HelpHint from '@/components/common/HelpHint.vue'
import {
DEFAULT_STICKY_KEY_ATTEMPTS,
createEmptyRoutingGroupConfig,
normalizeStickyKeyAttempts,
type RoutingModelPolicy,
type RoutingPriorityMode,
type RoutingSchedulingMode,
type RoutingGroupConfig,
} from '@/features/routing/utils/routingPolicy'
import { RoutingFailoverPolicyEditor, RoutingSchedulingPolicyEditor } from '@/features/routing/components'
import { normalizeProviderSchedulingConfig } from '@/features/routing/utils/schedulingPolicies'
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy, type RoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
import { createRoutingGroup, deleteRoutingGroup, listRoutingGroups, updateRoutingGroup, type RoutingGroupRecord } from '@/api/routing-profiles'
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
withDefaults(defineProps<{ providerRevision?: number }>(), { providerRevision: 0 })
const emit = defineEmits<{
'inspect-provider': [providerId: string]
saved: []
'context-change': [context: { groupId: string | null; groupName: string; config: RoutingGroupConfig | null; busy: boolean; activePolicy: SchedulingSelection | null; providerModelIds: string[] | undefined; priorityMode: RoutingPriorityMode; schedulingMode: RoutingSchedulingMode }]
}>()
interface SchedulingSelection {
id?: string | null
policy: RoutingModelPolicy | null
priorityMode: RoutingPriorityMode
schedulingMode: RoutingSchedulingMode
scope: 'all' | 'selected'
modelNames: string[]
}
interface RoutingGroupDraft {
id?: string
version: number
name: string
description: string
enabled: boolean
is_system_default: boolean
config_json: RoutingGroupConfig
}
const { success, error: showError } = useToast()
const { confirm } = useConfirm()
const route = useRoute()
const router = useRouter()
const groups = ref<RoutingGroupRecord[]>([])
const draft = ref<RoutingGroupDraft | null>(null)
const activePolicy = ref<SchedulingSelection | null>(null)
const initialSchedulingSelection = ref<Pick<SchedulingSelection, 'id' | 'scope' | 'modelNames'> | null>(null)
const routingSchedulingPolicyEditor = ref<{ updateSelectedPolicy: (policy: RoutingModelPolicy) => void } | null>(null)
const providerModelIds = computed(() => activePolicy.value?.scope === 'selected'
? globalModels.value.filter(model => activePolicy.value?.modelNames.includes(model.name)).map(model => model.id)
: undefined)
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
const routingFailoverPending = ref(false)
const routingSchedulingValid = ref(true)
const savedDraftSnapshot = ref<string | null>(null)
const globalModels = ref<GlobalModelResponse[]>([])
const loadingGlobalModels = ref(false)
const globalModelsError = ref<string | null>(null)
const loading = ref(true)
const loadingError = ref<string | null>(null)
const saving = ref(false)
const saveConflict = ref(false)
const deleting = ref(false)
const creating = ref(false)
const busy = computed(() => loading.value || saving.value || deleting.value || creating.value)
const createDialogOpen = ref(false)
const createForm = ref({ name: '', description: '', enabled: true })
const draftGeneration = ref(0)
const groupMetadata = ref<HTMLElement | null>(null)
const advancedOpen = ref(false)
const failoverOpen = ref(false)
const deleteDialogOpen = ref(false)
let internalNavigation = false
let discardConfirmation: Promise<boolean> | null = null
const routeGroupId = computed(() => queryToString(route.query.group))
const defaultGroupId = computed(() => groups.value.find(group => group.is_system_default)?.id ?? groups.value[0]?.id ?? null)
const selectedValue = computed(() => draft.value?.id ?? '')
const priorityMode = 'provider' as const
const schedulingMode = computed(() => activePolicy.value?.schedulingMode ?? draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity')
const emptyMessage = computed(() => loading.value ? '正在加载调度策略' : loadingError.value ?? (groups.value.length ? '未找到调度策略' : '还没有调度策略'))
const keepPriorityOnConversion = computed(() => draft.value?.config_json.default_policy.keep_priority_on_conversion ?? false)
const stickyKeyAttempts = computed(() => draft.value?.config_json.default_policy.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS)
const cfHeartbeat = computed(() => draft.value?.config_json.default_policy.enable_cf_heartbeat ?? false)
const cyberContinueFailover = computed(() => draft.value?.config_json.default_policy.cyber_continue_failover ?? false)
const cancelOnClientDisconnect = computed(() => draft.value?.config_json.default_policy.cancel_on_client_disconnect ?? false)
const draftDirty = computed(() => draft.value != null && (routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)))
const canSaveDraft = computed(() => Boolean(draft.value) && !busy.value && draftDirty.value && routingSchedulingValid.value)
function queryToString(value: unknown): string | null {
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : null
return typeof value === 'string' ? value : null
}
function normalizeRecord(group: RoutingGroupRecord): RoutingGroupRecord {
return { ...group, sort_order: Number.isFinite(group.sort_order) ? group.sort_order : 0, config_json: normalizeProviderSchedulingConfig(group.config_json) }
}
function sortGroups(items: RoutingGroupRecord[]): RoutingGroupRecord[] {
return [...items].sort((left, right) => {
if (left.enabled !== right.enabled) return left.enabled ? -1 : 1
return left.sort_order - right.sort_order || left.name.localeCompare(right.name) || left.id.localeCompare(right.id)
})
}
function cloneConfig(config: RoutingGroupConfig): RoutingGroupConfig {
return normalizeProviderSchedulingConfig(JSON.parse(JSON.stringify(config)) as Partial<RoutingGroupConfig>)
}
function draftSnapshotValue(value: RoutingGroupDraft): string {
return JSON.stringify({ name: value.name.trim(), description: value.description.trim() || null, enabled: value.enabled, is_system_default: value.is_system_default, config_json: cloneConfig(value.config_json) })
}
function resetEditors(): void {
activePolicy.value = null
initialSchedulingSelection.value = null
saveConflict.value = false
draftGeneration.value += 1
routingFailoverPending.value = false
routingSchedulingValid.value = true
deleteDialogOpen.value = false
}
function selectGroup(group: RoutingGroupRecord, preserveSelection = false): void {
const selection = preserveSelection && draft.value?.id === group.id ? activePolicy.value : null
resetEditors()
initialSchedulingSelection.value = selection
? { id: selection.id, scope: selection.scope, modelNames: [...selection.modelNames] }
: null
draft.value = { id: group.id, version: group.version, name: group.name, description: group.description ?? '', enabled: group.enabled, is_system_default: group.is_system_default, config_json: cloneConfig(group.config_json) }
savedDraftSnapshot.value = draftSnapshotValue(draft.value)
}
function openCreate(): void {
if (busy.value) return
createForm.value = { name: '', description: '', enabled: true }
createDialogOpen.value = true
}
function closeCreate(value: boolean): void { if (!busy.value) createDialogOpen.value = value }
function clearDraft(): void {
resetEditors()
draft.value = null
savedDraftSnapshot.value = null
}
function syncRouteState(): void {
if (loading.value) return
if (routeGroupId.value === 'new') {
if (!draft.value) { const group = groups.value.find(item => item.id === defaultGroupId.value); if (group) selectGroup(group) }
openCreate()
internalNavigation = true
void router.replace({ name: 'ProviderManagement', query: { ...route.query, view: undefined, group: draft.value?.id } }).finally(() => { internalNavigation = false })
return
}
const group = groups.value.find(item => item.id === (routeGroupId.value ?? defaultGroupId.value))
if (!group) { clearDraft(); return }
if (draft.value?.id !== group.id) selectGroup(group)
}
function openGroup(groupId: string | null): void {
if (busy.value) return
void router.push({ name: 'ProviderManagement', query: { ...route.query, view: undefined, group: groupId ?? undefined } })
}
function changeGroup(value: string): void {
// The route guard owns the switch; keep the controlled value until navigation succeeds.
openGroup(value)
}
async function confirmDiscard(): Promise<boolean> {
if (!draftDirty.value) return true
if (!discardConfirmation) {
discardConfirmation = confirm({ title: '有未保存的调度修改', message: '离开后将丢弃当前策略的未保存修改。是否继续?', confirmText: '放弃修改并离开', cancelText: '继续编辑', variant: 'warning' })
.finally(() => { discardConfirmation = null })
}
return discardConfirmation
}
async function guardNavigation(to: RouteLocationNormalized): Promise<boolean> {
if (internalNavigation) return true
const targetGroup = queryToString(to.query.group) ?? defaultGroupId.value
const staysOnDraft = to.name === 'ProviderManagement' && (targetGroup === selectedValue.value || targetGroup === 'new')
if (staysOnDraft) return true
if (busy.value) {
showError('正在保存调度设置,请稍候再切换')
return false
}
return confirmDiscard()
}
onBeforeRouteUpdate(guardNavigation)
onBeforeRouteLeave(guardNavigation)
function preventUnload(event: BeforeUnloadEvent): void {
if (!draftDirty.value && !busy.value) return
event.preventDefault()
event.returnValue = ''
}
function updateDraftConfig(value: RoutingGroupConfig): void {
if (draft.value) draft.value.config_json = normalizeProviderSchedulingConfig(value)
}
function updatePriorityPolicy(policy: RoutingModelPolicy): void {
if (busy.value) return
routingSchedulingPolicyEditor.value?.updateSelectedPolicy(policy)
}
function updateStickyKeyAttempts(value: string | number): void {
if (!draft.value) return
updateDraftConfig({ ...draft.value.config_json, default_policy: { ...draft.value.config_json.default_policy, sticky_key_attempts: normalizeStickyKeyAttempts(value) } })
}
function updateKeepPriorityOnConversion(value: boolean): void {
if (!draft.value) return
updateDraftConfig({ ...draft.value.config_json, default_policy: { ...draft.value.config_json.default_policy, keep_priority_on_conversion: value } })
}
function updateExecutionPolicy(field: 'enable_cf_heartbeat' | 'cyber_continue_failover' | 'cancel_on_client_disconnect', value: boolean): void {
if (!draft.value) return
updateDraftConfig({ ...draft.value.config_json, default_policy: { ...draft.value.config_json.default_policy, [field]: value } })
}
function updateRoutingFailoverPolicy(value: RoutingFailoverPolicy): void {
if (draft.value) Object.assign(draft.value.config_json.default_policy, normalizeRoutingFailoverPolicy(value))
}
function replaceGroup(group: RoutingGroupRecord, select: boolean, preserveSelection = false): void {
const normalized = normalizeRecord(group)
const otherGroups = groups.value.filter(item => item.id !== normalized.id).map(item => normalized.is_system_default ? { ...item, is_system_default: false } : item)
groups.value = sortGroups([...otherGroups, normalized])
if (select) selectGroup(normalized, preserveSelection)
}
async function refreshGroups(): Promise<void> {
const preservedDraft = draft.value
loading.value = true
loadingError.value = null
try {
const response = await listRoutingGroups()
groups.value = sortGroups(response.items.map(normalizeRecord))
} catch (err) {
loadingError.value = parseApiError(err, '加载调度策略失败')
showError(loadingError.value)
log.error('加载调度策略失败:', err)
} finally {
loading.value = false
if (!preservedDraft) syncRouteState()
}
}
async function reloadCurrentGroup(): Promise<void> {
if (busy.value || !draft.value?.id) return
if (draftDirty.value && !await confirm({ title: '重新加载最新分组', message: '重新加载会放弃当前未保存的修改。是否继续?', confirmText: '放弃修改并重新加载', cancelText: '保留修改', variant: 'warning' })) return
const groupId = draft.value.id
await refreshGroups()
if (loadingError.value) return
const group = groups.value.find(item => item.id === groupId)
if (group) selectGroup(group)
else { clearDraft(); syncRouteState() }
}
async function loadGlobalModels(options: { cacheTtlMs?: number } = {}): Promise<void> {
loadingGlobalModels.value = true
globalModelsError.value = null
try {
const response = await getGlobalModels({ limit: 1000, is_active: true }, { cacheTtlMs: options.cacheTtlMs ?? 0 })
globalModels.value = response.models ?? []
} catch (err) {
globalModels.value = []
globalModelsError.value = parseApiError(err, '加载全局模型失败')
log.error('加载全局模型失败:', err)
} finally {
loadingGlobalModels.value = false
}
}
async function saveDraft(): Promise<boolean> {
if (!draft.value?.id || busy.value) return false
const name = draft.value.name.trim()
if (!name) {
groupMetadata.value?.scrollIntoView?.({ block: 'nearest' })
groupMetadata.value?.querySelector<HTMLInputElement>('[aria-label="策略名称"]')?.focus()
showError('策略名称不能为空')
return false
}
if (routingFailoverPolicyEditor.value && !routingFailoverPolicyEditor.value.commitJsonDrafts()) { failoverOpen.value = true; return false }
const failoverError = validateRoutingFailoverPolicy(draft.value.config_json.default_policy)
if (failoverError) { failoverOpen.value = true; showError(failoverError); return false }
if (!routingSchedulingValid.value) { showError('请为每条调度配置选择适用模型'); return false }
const targetGroupId = draft.value.id
const submittedGeneration = draftGeneration.value
const submittedSnapshot = draftSnapshotValue(draft.value)
const payload = { name, description: draft.value.description.trim() || null, enabled: draft.value.enabled, is_system_default: draft.value.is_system_default, expected_version: draft.value.version, config_json: cloneConfig(draft.value.config_json) }
saving.value = true
try {
const saved = await updateRoutingGroup(targetGroupId, payload)
const unchanged = draftGeneration.value === submittedGeneration && draft.value?.id === targetGroupId && draftSnapshotValue(draft.value) === submittedSnapshot
replaceGroup(saved, unchanged, true)
success('调度策略已保存')
emit('saved')
return unchanged
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status
saveConflict.value = status === 409
showError(status === 409 ? '此分组已在其他操作中更新。当前修改已保留,请重新加载最新分组后再编辑。' : parseApiError(err, '保存调度策略失败'))
log.error('保存调度策略失败:', err)
return false
} finally { saving.value = false }
}
async function ensureSaved(): Promise<boolean> {
if (busy.value || !draft.value) return false
if (!draftDirty.value) return true
const approved = await confirm({ title: '先保存当前分组', message: '当前分组有未保存的修改。保存后继续?', confirmText: '保存并继续', cancelText: '继续编辑', variant: 'question' })
return approved && await saveDraft()
}
async function createGroup(): Promise<void> {
if (busy.value || !createForm.value.name.trim()) return
if (draftDirty.value && !await ensureSaved()) return
creating.value = true
try {
const saved = await createRoutingGroup({ name: createForm.value.name.trim(), description: createForm.value.description.trim() || null, enabled: createForm.value.enabled, is_system_default: groups.value.length === 0, sort_order: groups.value.length, config_json: createEmptyRoutingGroupConfig() })
replaceGroup(saved, true)
createDialogOpen.value = false
internalNavigation = true
try { await router.replace({ name: 'ProviderManagement', query: { ...route.query, view: undefined, group: saved.id } }) }
finally { internalNavigation = false }
success('策略分组已创建')
emit('saved')
} catch (err) { showError(parseApiError(err, '创建策略分组失败')); log.error('创建策略分组失败:', err) }
finally { creating.value = false }
}
async function confirmDeleteDraft(): Promise<void> {
if (!draft.value?.id || busy.value) return
const targetId = draft.value.id
deleting.value = true
try {
await deleteRoutingGroup(targetId)
groups.value = groups.value.filter(group => group.id !== targetId)
clearDraft()
internalNavigation = true
try {
await router.replace({ name: 'ProviderManagement', query: { ...route.query, view: undefined, group: defaultGroupId.value ?? undefined } })
syncRouteState()
} finally {
internalNavigation = false
}
success('调度策略已删除')
emit('saved')
} catch (err) {
showError(parseApiError(err, '删除调度策略失败'))
log.error('删除调度策略失败:', err)
} finally {
deleting.value = false
}
}
onMounted(() => {
void refreshGroups()
void loadGlobalModels({ cacheTtlMs: 60_000 })
window.addEventListener('beforeunload', preventUnload)
})
onBeforeUnmount(() => window.removeEventListener('beforeunload', preventUnload))
watch(() => [route.name, route.query.group], syncRouteState)
watch(() => [draft.value, busy.value, activePolicy.value, providerModelIds.value], () => emit('context-change', { groupId: draft.value?.id ?? null, groupName: draft.value?.name ?? '', config: draft.value?.config_json ?? null, busy: busy.value, activePolicy: activePolicy.value, providerModelIds: providerModelIds.value, priorityMode, schedulingMode: schedulingMode.value }), { deep: true, immediate: true })
defineExpose({ updateDraftConfig, updatePriorityPolicy, refreshGroups, ensureSaved })
</script>
@@ -21,27 +21,6 @@
/>
</div>
<!-- 状态筛选 -->
<div :class="{ 'xl:hidden': !cardView }">
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue :placeholder="legacyT('全部状态')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="status in statusFilters"
:key="status.value"
:value="status.value"
>
{{ legacyT(status.label) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- API 格式筛选 -->
<div :class="{ 'xl:hidden': !cardView }">
<Select
@@ -64,7 +43,10 @@
</div>
<!-- 模型筛选 -->
<div :class="{ 'xl:hidden': !cardView }">
<div
v-if="showModelFilter !== false"
:class="{ 'xl:hidden': !cardView }"
>
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
@@ -159,22 +141,20 @@ import RefreshButton from '@/components/ui/refresh-button.vue'
import type { FilterOption } from '@/features/providers/composables/useProviderFilters'
import { useI18n } from '@/i18n'
defineProps<{
withDefaults(defineProps<{
searchQuery: string
filterStatus: string
filterApiFormat: string
showModelFilter?: boolean
filterModel: string
statusFilters: FilterOption[]
apiFormatFilters: FilterOption[]
modelFilters: FilterOption[]
hasActiveFilters: boolean
loading: boolean
cardView: boolean
}>()
}>(), { showModelFilter: true })
defineEmits<{
'update:searchQuery': [value: string]
'update:filterStatus': [value: string]
'update:filterApiFormat': [value: string]
'update:filterModel': [value: string]
'resetFilters': []
@@ -11,6 +11,17 @@
>
<slot name="drag-handle" />
</TableCell>
<TableCell
v-if="$slots.priority"
class="w-px whitespace-nowrap px-2 py-3.5 text-center"
@click.stop
@mousedown.stop
@pointerdown.stop
>
<div class="flex justify-center">
<slot name="priority" />
</div>
</TableCell>
<TableCell class="py-3.5">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
@@ -136,18 +147,22 @@
>{{ legacyT('暂无端点') }}</span>
</TableCell>
<TableCell class="py-3.5 text-center">
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs"
>
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
</Badge>
<div class="flex flex-col items-center gap-1.5">
<slot name="scheduling" />
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="whitespace-nowrap text-xs"
>
{{ legacyT(provider.is_active ? '全局启用' : '全局停用') }}
</Badge>
</div>
</TableCell>
<TableCell
class="py-3.5"
@click.stop
>
<div class="flex items-center justify-center gap-0.5">
<slot name="group-action" />
<Button
variant="ghost"
size="icon"
@@ -179,7 +194,7 @@
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
:title="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
:title="legacyT(provider.is_active ? '全局停用提供商' : '全局启用提供商')"
@click="$emit('toggleStatus', provider)"
>
<Power class="h-3.5 w-3.5" />
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints/types'
import ProviderFormDialog from '../ProviderFormDialog.vue'
const api = vi.hoisted(() => ({ createProvider: vi.fn(), updateProvider: vi.fn() }))
vi.mock('@/api/endpoints', () => ({ ...api, normalizePoolAdvancedConfig: () => null }))
vi.mock('@/composables/useToast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) }))
let app: App | null = null
let root: HTMLElement | null = null
async function settle() {
for (let index = 0; index < 4; index += 1) { await Promise.resolve(); await nextTick() }
}
async function mountDialog(props: { routingGroupId?: string; routingGroupName?: string; provider?: ProviderWithEndpointsSummary } = {}) {
root = document.createElement('div')
document.body.appendChild(root)
app = createApp(ProviderFormDialog, { modelValue: true, ...props })
app.mount(root)
await settle()
}
async function submit(text: string) {
const button = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === text)
expect(button).toBeDefined()
button!.click()
await settle()
}
async function nameProvider() {
const input = document.body.querySelector<HTMLInputElement>('#name')!
input.value = 'New Provider'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
api.createProvider.mockResolvedValue({ id: 'new-provider' })
api.updateProvider.mockResolvedValue({ id: 'existing-provider' })
})
afterEach(() => { app?.unmount(); root?.remove(); document.body.innerHTML = ''; app = null; root = null })
describe('ProviderFormDialog routing group assignment', () => {
it('explains the creation scope and submits the selected routing group', async () => {
await mountDialog({ routingGroupId: 'group-b', routingGroupName: '备用策略' })
expect(document.body.textContent).toContain('备用策略')
expect(document.body.textContent).toContain('其他策略分组中默认禁用')
await nameProvider()
await submit('创建')
expect(api.createProvider).toHaveBeenCalledWith(expect.objectContaining({ name: 'New Provider', routing_group_id: 'group-b' }))
expect(api.updateProvider).not.toHaveBeenCalled()
})
it('keeps creation without a group compatible with the existing API', async () => {
await mountDialog()
expect(document.body.textContent).not.toContain('其他策略分组中默认禁用')
await nameProvider()
await submit('创建')
expect(api.createProvider).toHaveBeenCalledOnce()
expect(api.createProvider.mock.calls[0]![0].routing_group_id).toBeUndefined()
})
it('does not change group membership when editing an existing provider', async () => {
await mountDialog({
routingGroupId: 'group-b', routingGroupName: '备用策略',
provider: { id: 'existing-provider', name: 'Existing', provider_type: 'custom', provider_priority: 10, is_active: true } as ProviderWithEndpointsSummary,
})
expect(document.body.textContent).not.toContain('其他策略分组中默认禁用')
await submit('保存')
expect(api.updateProvider).toHaveBeenCalledWith('existing-provider', expect.objectContaining({ name: 'Existing' }))
expect(api.updateProvider.mock.calls[0]![1]).not.toHaveProperty('routing_group_id')
expect(api.createProvider).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import ProviderGroupControls from '../ProviderGroupControls.vue'
let app: App | null = null
let root: HTMLElement | null = null
function mountControls(disabled = false, showPriority = true) {
const priority = ref(10)
const enabled = ref(true)
const busy = ref(disabled)
const priorityChanged = vi.fn((value: number) => { priority.value = value })
const rowClicked = vi.fn()
const rowMouseDown = vi.fn()
const rowPointerDown = vi.fn()
root = document.createElement('div')
document.body.appendChild(root)
app = createApp({
setup: () => () => h('div', { onClick: rowClicked, onMousedown: rowMouseDown, onPointerdown: rowPointerDown }, [
h(ProviderGroupControls, {
providerName: 'Provider One', priority: priority.value, enabled: enabled.value, disabled: busy.value, showPriority,
'onUpdate:priority': priorityChanged,
}),
]),
})
app.mount(root)
return {
priority, enabled, busy, priorityChanged, rowClicked, rowMouseDown, rowPointerDown,
button: () => root!.querySelector<HTMLButtonElement>('button[aria-label="Provider One 的组内优先级"]'),
input: () => root!.querySelector<HTMLInputElement>('input[aria-label="Provider One 的组内优先级"]'),
}
}
async function beginEditing(controls: ReturnType<typeof mountControls>): Promise<HTMLInputElement> {
controls.button()?.click()
await nextTick()
const input = controls.input()
expect(input).toBeTruthy()
return input!
}
function setInput(input: HTMLInputElement, value: string): void {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
afterEach(() => { app?.unmount(); root?.remove(); app = null; root = null })
describe('ProviderGroupControls', () => {
it.each([0, 24, 2147483647])('submits valid group priority %i once on blur', async priority => {
const controls = mountControls()
expect(controls.input()).toBeNull()
const input = await beginEditing(controls)
setInput(input, String(priority))
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(controls.priorityChanged).toHaveBeenCalledExactlyOnceWith(priority)
expect(controls.button()?.textContent).toContain(String(priority))
expect(controls.input()).toBeNull()
})
it.each(['', '-1', '1.5', '2147483648'])('restores the saved priority for invalid input %j', async value => {
const controls = mountControls()
const input = await beginEditing(controls)
setInput(input, value)
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(controls.priorityChanged).not.toHaveBeenCalled()
expect(controls.button()?.textContent).toContain('10')
expect(controls.input()).toBeNull()
})
it.each([true, false])('shows enablement %s as a read-only badge when priority is hidden', async enabled => {
const controls = mountControls(false, false)
controls.enabled.value = enabled
await nextTick()
expect(root!.textContent?.trim()).toBe(enabled ? '本组启用' : '本组禁用')
expect(root!.querySelector('button, input, [role="switch"]')).toBeNull()
})
it('keeps priority editing from triggering row actions', async () => {
const controls = mountControls()
controls.button()!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
controls.button()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
const input = await beginEditing(controls)
input.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
input.click()
await nextTick()
expect(controls.priorityChanged).not.toHaveBeenCalled()
expect(controls.rowClicked).not.toHaveBeenCalled()
expect(controls.rowMouseDown).not.toHaveBeenCalled()
expect(controls.rowPointerDown).not.toHaveBeenCalled()
})
it('disables priority editing while the group is busy', async () => {
const controls = mountControls(true)
expect(controls.button()?.disabled).toBe(true)
controls.button()?.click()
await nextTick()
expect(controls.input()).toBeNull()
expect(controls.priorityChanged).not.toHaveBeenCalled()
expect(root!.textContent).toContain('本组启用')
})
})
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import ProviderPriorityInput from '../ProviderPriorityInput.vue'
const mounted: Array<{ app: App, root: HTMLElement }> = []
function mountInput() {
const priority = ref(10)
const disabled = ref(false)
const editContext = ref('group-one:all-models')
const changed = vi.fn((value: number) => { priority.value = value })
const rowEvents = { click: vi.fn(), mousedown: vi.fn(), pointerdown: vi.fn(), keydown: vi.fn() }
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup: () => () => h('div', {
onClick: rowEvents.click, onMousedown: rowEvents.mousedown,
onPointerdown: rowEvents.pointerdown, onKeydown: rowEvents.keydown,
}, [h(ProviderPriorityInput, {
providerName: 'Provider One', priority: priority.value, disabled: disabled.value,
editContext: editContext.value,
'onUpdate:priority': changed,
})]),
})
app.mount(root)
mounted.push({ app, root })
return {
priority, disabled, editContext, changed, rowEvents,
button: () => root.querySelector<HTMLButtonElement>('button[aria-label="Provider One 的组内优先级"]'),
input: () => root.querySelector<HTMLInputElement>('input[aria-label="Provider One 的组内优先级"]'),
}
}
async function edit(control: ReturnType<typeof mountInput>, value = '24') {
control.button()!.click()
await nextTick()
const input = control.input()!
expect(input).toBeTruthy()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
return input
}
afterEach(() => {
for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() }
vi.restoreAllMocks()
})
describe('ProviderPriorityInput', () => {
it('shows only a number button until clicked, then focuses and selects the saved value', async () => {
const select = vi.spyOn(HTMLInputElement.prototype, 'select')
const control = mountInput()
expect(control.input()).toBeNull()
expect(control.button()?.textContent?.trim()).toBe('10')
control.button()!.click()
await nextTick()
expect(document.activeElement).toBe(control.input())
expect(control.input()?.value).toBe('10')
expect(select).toHaveBeenCalledOnce()
})
it('submits Enter once and restores button focus before ignoring a trailing blur', async () => {
const control = mountInput()
const input = await edit(control)
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await nextTick()
input.dispatchEvent(new Event('blur', { bubbles: true }))
await nextTick()
expect(control.changed).toHaveBeenCalledExactlyOnceWith(24)
expect(control.input()).toBeNull()
expect(document.activeElement).toBe(control.button())
})
it('cancels Escape without submitting and restores the original value and button focus', async () => {
const control = mountInput()
const input = await edit(control)
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await nextTick()
input.dispatchEvent(new Event('blur', { bubbles: true }))
expect(control.changed).not.toHaveBeenCalled()
expect(control.input()).toBeNull()
expect(control.button()?.textContent?.trim()).toBe('10')
expect(document.activeElement).toBe(control.button())
})
it.each(['priority', 'disabled', 'editContext'] as const)('discards unfinished drafts when %s changes', async changedProp => {
const control = mountInput()
const oldInput = await edit(control)
if (changedProp === 'priority') control.priority.value = 99
else if (changedProp === 'disabled') control.disabled.value = true
else control.editContext.value = 'group-two:claude-sonnet'
await nextTick()
expect(control.input()).toBeNull()
oldInput.dispatchEvent(new Event('blur', { bubbles: true }))
oldInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
expect(control.changed).not.toHaveBeenCalled()
expect(control.button()?.textContent?.trim()).toBe(changedProp === 'priority' ? '99' : '10')
if (changedProp === 'disabled') expect(control.button()?.disabled).toBe(true)
})
it('blocks editing while disabled', async () => {
const control = mountInput()
control.disabled.value = true
await nextTick()
control.button()!.click()
await nextTick()
expect(control.input()).toBeNull()
expect(control.changed).not.toHaveBeenCalled()
})
it('stops row click, drag-starting pointer events, and keyboard shortcuts in both display and editing states', async () => {
const control = mountInput()
control.button()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
control.button()!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
control.button()!.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
const input = await edit(control)
input.click()
input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
input.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
for (const listener of Object.values(control.rowEvents)) expect(listener).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,354 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type LocationQueryRaw } from 'vue-router'
import ProviderSchedulingView from '../ProviderSchedulingView.vue'
import { createEmptyRoutingGroupConfig, type RoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
const routingApi = vi.hoisted(() => ({ listRoutingGroups: vi.fn(), updateRoutingGroup: vi.fn(), createRoutingGroup: vi.fn(), deleteRoutingGroup: vi.fn() }))
const confirm = vi.hoisted(() => vi.fn())
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
const inspectProvider = vi.hoisted(() => vi.fn())
const contextChange = vi.hoisted(() => vi.fn())
vi.mock('@/api/routing-profiles', () => routingApi)
vi.mock('@/api/global-models', () => ({ getGlobalModels: vi.fn().mockResolvedValue({ models: [] }) }))
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
vi.mock('@/composables/useConfirm', () => ({ useConfirm: () => ({ confirm }) }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
vi.mock('@/features/providers/composables/useSchedulingProviderBalance', () => ({ provideSchedulingProviderBalance: vi.fn() }))
vi.mock('../ProviderSchedulingStatus.vue', () => ({ default: { render: () => null } }))
vi.mock('@/features/routing/components', async () => {
const { defineComponent, h } = await import('vue')
return {
RoutingFailoverPolicyEditor: defineComponent({ setup(_, { expose }) { expose({ commitJsonDrafts: () => true }); return () => null } }),
RoutingSchedulingPolicyEditor: defineComponent({
props: { refreshRevision: Number, layout: String },
emits: ['inspect-provider'],
setup(props, { emit, slots }) {
return () => h('div', [
slots['toolbar-leading']?.(),
h('button', { 'aria-label': '查看示例提供商', 'data-revision': props.refreshRevision, 'data-layout': props.layout, onClick: () => emit('inspect-provider', 'provider-a') }, '示例提供商'),
slots['toolbar-actions']?.(),
])
},
}),
}
})
const mounted: Array<{ app: App; root: HTMLElement }> = []
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
function group(id: string, overrides: Partial<RoutingGroupRecord> = {}): RoutingGroupRecord {
return { id, name: id, enabled: true, is_system_default: false, sort_order: 0, config_json: createEmptyRoutingGroupConfig(), version: 1, created_at: 1, updated_at: 1, ...overrides }
}
async function flush() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
async function mountWorkspace(query: LocationQueryRaw = {}, items = [group('first'), group('default', { is_system_default: true }), group('last')]) {
routingApi.listRoutingGroups.mockResolvedValue({ items, total: items.length })
routingApi.updateRoutingGroup.mockImplementation(async (id: string, payload: RoutingGroupUpdateRequest) => ({ ...items.find(item => item.id === id), ...payload, version: 2 }))
routingApi.createRoutingGroup.mockImplementation(async payload => group('created', payload))
const providerRevision = ref(0)
const workspace = ref<{ updateDraftConfig: (config: RoutingGroupConfig) => void; refreshGroups: () => Promise<void>; ensureSaved: () => Promise<boolean> } | null>(null)
const Providers = defineComponent({
setup() {
return () => h(ProviderSchedulingView, { ref: workspace, providerRevision: providerRevision.value, onInspectProvider: inspectProvider, onContextChange: contextChange }, { default: () => h('div', { 'data-testid': 'provider-directory' }, '提供商目录') })
},
})
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/providers', name: 'ProviderManagement', component: Providers },
{ path: '/other', name: 'Other', component: { render: () => h('div', '其他页面') } },
] })
await router.push({ name: 'ProviderManagement', query })
await router.isReady()
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({ render: () => h(RouterView) })
app.use(router)
app.mount(root)
mounted.push({ app, root })
await flush()
return { root, router, providerRevision, workspace }
}
function element<T extends HTMLElement>(root: HTMLElement, selector: string): T {
const found = root.querySelector<T>(selector) ?? document.body.querySelector<T>(selector)
if (!found) throw new Error(`Missing ${selector}`)
return found
}
function button(root: HTMLElement, label: string) {
return element<HTMLButtonElement>(root, `button[aria-label="${label}"]`)
}
function selector(root: HTMLElement) {
return element<HTMLButtonElement>(root, 'button[role="combobox"][aria-label="当前调度策略"]')
}
async function openGroupOptions(root: HTMLElement) {
selector(root).focus()
selector(root).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await flush()
return [...document.querySelectorAll<HTMLElement>('[role="listbox"] [role="option"]')]
}
async function chooseGroup(root: HTMLElement, label: string) {
const option = (await openGroupOptions(root)).find(item => item.textContent?.trim() === label)
if (!option) throw new Error(`Missing group option: ${label}`)
option.focus()
option.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
await flush()
}
async function editName(root: HTMLElement, name: string) {
const input = element<HTMLInputElement>(root, '[aria-label="策略名称"]')
input.value = name
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
confirm.mockResolvedValue(false)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: vi.fn(), configurable: true })
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() }
vi.unstubAllGlobals()
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
})
describe('ProviderSchedulingView workspace navigation', () => {
it('opens the system default immediately and forwards provider inspection and refreshes', async () => {
const { root, providerRevision } = await mountWorkspace()
expect(selector(root).textContent?.trim()).toBe('default · 默认')
expect(button(root, '保存调度').disabled).toBe(true)
expect(root.querySelector('[data-testid="provider-directory"]')).not.toBeNull()
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(button(root, '查看示例提供商').dataset.layout).toBe('config-only')
const directory = element(root, '[data-testid="provider-directory"]')
const combined = element(root, '[aria-label="策略分组与调度配置"]')
expect(combined.contains(selector(root))).toBe(true)
expect(combined.contains(button(root, '查看示例提供商'))).toBe(true)
expect(combined.contains(directory)).toBe(false)
const header = element(combined, 'h3').parentElement!
expect(header.contains(selector(root))).toBe(true)
expect(header.contains(button(root, '新建策略'))).toBe(true)
expect(header.contains(button(root, '删除策略'))).toBe(true)
expect(header.contains(button(root, '保存调度'))).toBe(true)
expect(header.contains(button(root, '设为系统默认'))).toBe(true)
const headerButtons = [...header.querySelectorAll<HTMLButtonElement>('button')]
expect(headerButtons[headerButtons.indexOf(button(root, '保存调度')) - 1]).toBe(button(root, '设为系统默认'))
expect(button(root, '设为系统默认').textContent?.trim()).toBe('')
expect(button(root, '设为系统默认').getAttribute('aria-pressed')).toBe('true')
expect(button(root, '保存调度').textContent?.trim()).toBe('')
expect(button(root, '新建策略').textContent?.trim()).toBe('')
expect(button(root, '删除策略').textContent?.trim()).toBe('')
expect(combined.querySelector('[role="status"]')).toBeNull()
expect(combined.querySelector('[aria-label="撤销修改"]')).toBeNull()
expect(button(root, '查看示例提供商').compareDocumentPosition(directory) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(button(root, '故障转移').getAttribute('aria-expanded')).toBe('false')
expect(button(root, '高级设置').getAttribute('aria-expanded')).toBe('false')
expect(combined.contains(button(root, '故障转移'))).toBe(true)
expect(combined.contains(button(root, '高级设置'))).toBe(true)
expect(root.querySelector('[aria-label="分组管理"]')).toBeNull()
expect(root.querySelector('[aria-label="管理策略"]')).toBeNull()
const controlOrder = [
selector(root),
element(root, '[aria-label="策略名称"]'),
button(root, '启用策略'),
element(root, '[aria-label="策略描述"]'),
button(root, '查看示例提供商'),
button(root, '高级设置'),
button(root, '故障转移'),
]
for (let index = 1; index < controlOrder.length; index += 1) {
expect(controlOrder[index - 1].compareDocumentPosition(controlOrder[index]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
button(root, '查看示例提供商').click()
expect(inspectProvider).toHaveBeenCalledWith('provider-a')
await editName(root, '保持草稿')
providerRevision.value += 1
await nextTick()
expect(button(root, '查看示例提供商').dataset.revision).toBe('1')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('保持草稿')
expect(button(root, '保存调度').disabled).toBe(false)
})
it('keeps the route, dropdown and draft unchanged when a strategy switch is cancelled', async () => {
const { root, router } = await mountWorkspace()
await editName(root, '未保存名称')
await chooseGroup(root, 'last')
expect(confirm).toHaveBeenCalledOnce()
expect(router.currentRoute.value.query.group).toBeUndefined()
expect(selector(root).textContent?.trim()).toBe('default · 默认')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('未保存名称')
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
const options = await openGroupOptions(root)
expect(options.find(option => option.getAttribute('aria-selected') === 'true')?.textContent?.trim()).toBe('default · 默认')
expect(options.find(option => option.textContent?.trim() === 'last')?.getAttribute('aria-selected')).toBe('false')
})
it('switches only after discard is confirmed and resets the saved state', async () => {
const { root, router } = await mountWorkspace()
await editName(root, '未保存名称')
confirm.mockResolvedValue(true)
await chooseGroup(root, 'last')
expect(router.currentRoute.value.query.group).toBe('last')
expect(selector(root).textContent?.trim()).toBe('last')
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('last')
expect(button(root, '保存调度').disabled).toBe(true)
})
it('protects leaving the provider page while unrelated query changes preserve the directory', async () => {
const { root, router } = await mountWorkspace()
await editName(root, '需要保护')
await router.push({ name: 'ProviderManagement', query: { search: 'sample' } })
expect(confirm).not.toHaveBeenCalled()
await router.push({ name: 'Other' })
expect(router.currentRoute.value.name).toBe('ProviderManagement')
expect(confirm).toHaveBeenCalledOnce()
confirm.mockResolvedValue(true)
await router.push({ name: 'Other' })
expect(router.currentRoute.value.name).toBe('Other')
})
it('blocks navigation while saving and restores a clean snapshot after success', async () => {
const { root, router } = await mountWorkspace()
await editName(root, '已保存名称')
let resolveSave!: (value: RoutingGroupRecord) => void
routingApi.updateRoutingGroup.mockReturnValue(new Promise<RoutingGroupRecord>(resolve => { resolveSave = resolve }))
button(root, '保存调度').click()
await nextTick()
await router.push({ name: 'Other' })
expect(router.currentRoute.value.name).toBe('ProviderManagement')
expect(confirm).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('正在保存调度设置,请稍候再切换')
resolveSave(group('default', { name: '已保存名称', is_system_default: true }))
await flush()
expect(button(root, '保存调度').disabled).toBe(true)
await router.push({ name: 'Other' })
expect(router.currentRoute.value.name).toBe('Other')
})
it('retains edits after a failed save and can retry without changing route', async () => {
const { root, router } = await mountWorkspace()
await editName(root, '失败后保留')
routingApi.updateRoutingGroup.mockRejectedValue(new Error('offline'))
button(root, '保存调度').click()
await flush()
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('失败后保留')
expect(button(root, '保存调度').disabled).toBe(false)
routingApi.updateRoutingGroup.mockResolvedValue(group('default', { name: '失败后保留', is_system_default: true }))
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(2)
expect(routingApi.updateRoutingGroup).toHaveBeenLastCalledWith('default', expect.objectContaining({ name: '失败后保留', expected_version: 1 }))
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('失败后保留')
expect(button(root, '保存调度').disabled).toBe(true)
expect(router.currentRoute.value.query.group).toBeUndefined()
})
it('opens creation independently and cancellation preserves the selected group and draft', async () => {
const { root, router } = await mountWorkspace()
await editName(root, '保留的分组草稿')
button(root, '新建策略').click()
await nextTick()
expect(selector(root).textContent?.trim()).toBe('default · 默认')
expect(router.currentRoute.value.query.group).toBeUndefined()
expect(document.querySelector('[role="dialog"][aria-label="新建策略分组"]')).not.toBeNull()
button(root, '取消新建分组').click()
await nextTick()
expect(button(root, '保存调度').disabled).toBe(false)
expect(contextChange.mock.lastCall?.[0].groupName).toBe('保留的分组草稿')
expect(routingApi.createRoutingGroup).not.toHaveBeenCalled()
expect(confirm).not.toHaveBeenCalled()
})
it('creates in a dialog and switches only after the request succeeds', async () => {
const { root, router } = await mountWorkspace({ group: 'new' })
expect(selector(root).textContent?.trim()).toBe('default · 默认')
expect(router.currentRoute.value.query.group).toBe('default')
const name = element<HTMLInputElement>(root, '[aria-label="新分组名称"]')
name.value = '新策略'
name.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
let finish!: (group: RoutingGroupRecord) => void
routingApi.createRoutingGroup.mockReturnValue(new Promise(resolve => { finish = resolve }))
button(root, '创建策略分组').click()
await nextTick()
expect(selector(root).textContent?.trim()).toBe('default · 默认')
finish(group('created', { name: '新策略' }))
await flush()
expect(routingApi.createRoutingGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '新策略', config_json: expect.objectContaining({ disabled_providers: [] }) }))
expect(router.currentRoute.value.query.group).toBe('created')
expect(selector(root).textContent?.trim()).toBe('新策略')
expect(button(root, '保存调度').disabled).toBe(true)
expect(confirm).not.toHaveBeenCalled()
})
it('saves strategy metadata with scheduling and updates the default marker', async () => {
const { root } = await mountWorkspace({ group: 'last' })
await editName(root, '主要策略')
expect(button(root, '设为系统默认').getAttribute('aria-pressed')).toBe('false')
button(root, '设为系统默认').click()
await nextTick()
expect(button(root, '设为系统默认').getAttribute('aria-pressed')).toBe('true')
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledWith('last', expect.objectContaining({ name: '主要策略', is_system_default: true, config_json: expect.any(Object) }))
expect(selector(root).textContent?.trim()).toBe('主要策略 · 默认')
const options = await openGroupOptions(root)
expect(options.find(option => option.getAttribute('aria-selected') === 'true')?.textContent?.trim()).toBe('主要策略 · 默认')
expect(options.find(option => option.textContent?.trim() === 'default')?.getAttribute('aria-selected')).toBe('false')
expect(button(root, '设为系统默认').getAttribute('aria-pressed')).toBe('true')
expect(button(root, '保存调度').disabled).toBe(true)
})
it('accepts directory changes and saves membership with the draft base version', async () => {
const { root, workspace } = await mountWorkspace()
const config = createEmptyRoutingGroupConfig()
config.disabled_providers = ['provider-a']
workspace.value?.updateDraftConfig(config)
await nextTick()
expect(contextChange.mock.lastCall?.[0].config.disabled_providers).toEqual(['provider-a'])
confirm.mockResolvedValue(true)
expect(await workspace.value?.ensureSaved()).toBe(true)
expect(routingApi.updateRoutingGroup).toHaveBeenCalledWith('default', expect.objectContaining({ expected_version: 1, config_json: expect.objectContaining({ disabled_providers: ['provider-a'] }) }))
await flush()
expect(button(root, '保存调度').disabled).toBe(true)
})
it('preserves a stale draft version after refreshing groups and shows a save conflict', async () => {
const { root, workspace } = await mountWorkspace()
await editName(root, '我的修改')
routingApi.listRoutingGroups.mockResolvedValue({ items: [group('default', { is_system_default: true, version: 9, name: '其他人的修改' })] })
await workspace.value?.refreshGroups()
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('我的修改')
routingApi.updateRoutingGroup.mockRejectedValue({ response: { status: 409 } })
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledWith('default', expect.objectContaining({ expected_version: 1 }))
expect(button(root, '保存调度').disabled).toBe(false)
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('其他操作中更新'))
confirm.mockResolvedValue(true)
button(root, '重新加载分组').click()
await flush()
expect(element<HTMLInputElement>(root, '[aria-label="策略名称"]').value).toBe('其他人的修改')
await editName(root, '基于新版修改')
routingApi.updateRoutingGroup.mockResolvedValue(group('default', { version: 10, name: '基于新版修改', is_system_default: true }))
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenLastCalledWith('default', expect.objectContaining({ expected_version: 9 }))
expect(button(root, '保存调度').disabled).toBe(true)
})
it('warns before a tab refresh only when there are unsaved changes', async () => {
const { root } = await mountWorkspace()
const clean = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(clean)
expect(clean.defaultPrevented).toBe(false)
await editName(root, '刷新保护')
const dirty = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(dirty)
expect(dirty.defaultPrevented).toBe(true)
})
})
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { ActionResultResponse } from '@/api/providerOps'
import { provideSchedulingProviderBalance, useSchedulingProviderBalance } from '../useSchedulingProviderBalance'
const api = vi.hoisted(() => ({
batchQueryBalance: vi.fn<(providerIds: string[]) => Promise<Record<string, ActionResultResponse>>>(),
getArchitectures: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/api/providerOps', () => api)
let app: App | undefined
let root: HTMLDivElement
function mountBalance() {
const revision = ref(0)
let balance!: NonNullable<ReturnType<typeof useSchedulingProviderBalance>>
const Child = defineComponent({ setup() { balance = useSchedulingProviderBalance()!; return () => null } })
root = document.createElement('div')
app = createApp({
setup() { provideSchedulingProviderBalance(() => revision.value); return () => h(Child) },
})
app.mount(root)
return { balance, revision }
}
function result(status: ActionResultResponse['status'], available = 0): ActionResultResponse {
return { status, action_type: 'query_balance', data: { total_available: available, currency: 'USD', extra: {} }, message: null, executed_at: '2026-10-05T00:00:00Z', response_time_ms: 0, cache_ttl_seconds: 0 }
}
function provider(id: string, opsConfigured = true) { return { id, ops_configured: opsConfigured } }
beforeEach(() => { vi.useFakeTimers(); api.batchQueryBalance.mockReset() })
afterEach(() => { app?.unmount(); app = undefined; root?.remove(); vi.useRealTimers() })
describe('shared scheduling provider balances', () => {
it('deduplicates providers repeated across model panels and skips unconfigured providers', async () => {
const { balance } = mountBalance()
api.batchQueryBalance.mockResolvedValue({ a: result('success', 0), b: result('success', 10) })
balance.register(provider('a'))
balance.register(provider('a'))
balance.register(provider('b'))
balance.register(provider('without-ops', false))
await vi.advanceTimersByTimeAsync(80)
expect(api.batchQueryBalance).toHaveBeenCalledExactlyOnceWith(['a', 'b'])
expect(balance.getProviderBalance('a')).toEqual({ available: 0, currency: 'USD' })
balance.register(provider('a'))
await vi.advanceTimersByTimeAsync(80)
expect(api.batchQueryBalance).toHaveBeenCalledOnce()
})
it('continues retrying pending balances when another model panel registers providers', async () => {
const { balance } = mountBalance()
api.batchQueryBalance
.mockResolvedValueOnce({ a: result('pending') })
.mockResolvedValueOnce({ a: result('pending'), b: result('success', 20) })
.mockResolvedValueOnce({ a: result('success', 10) })
balance.register(provider('a'))
await vi.advanceTimersByTimeAsync(80)
expect(balance.isBalanceLoading('a')).toBe(true)
balance.register(provider('b'))
await vi.advanceTimersByTimeAsync(80)
await vi.advanceTimersByTimeAsync(12_000)
expect(api.batchQueryBalance.mock.calls).toEqual([[['a']], [['a', 'b']], [['a']]])
expect(balance.getProviderBalance('a')).toEqual({ available: 10, currency: 'USD' })
expect(balance.getProviderBalance('b')).toEqual({ available: 20, currency: 'USD' })
})
it('serializes loads and coalesces registrations and revisions arriving during a request', async () => {
const { balance, revision } = mountBalance()
let finishFirst!: (results: Record<string, ActionResultResponse>) => void
api.batchQueryBalance
.mockImplementationOnce(() => new Promise(resolve => { finishFirst = resolve }))
.mockResolvedValueOnce({ a: result('success', 12), b: result('success', 20), c: result('success', 30) })
balance.register(provider('a'))
await vi.advanceTimersByTimeAsync(80)
balance.register(provider('b'))
balance.register(provider('c'))
revision.value += 1
await nextTick()
await vi.advanceTimersByTimeAsync(800)
expect(api.batchQueryBalance).toHaveBeenCalledOnce()
finishFirst({ a: result('success', 10) })
await vi.advanceTimersByTimeAsync(80)
expect(api.batchQueryBalance.mock.calls).toEqual([[['a']], [['a', 'b', 'c']]])
expect(balance.getProviderBalance('a')?.available).toBe(12)
})
it('refreshes previously registered balances when provider details change', async () => {
const { balance, revision } = mountBalance()
api.batchQueryBalance.mockResolvedValueOnce({ a: result('success', 10) }).mockResolvedValueOnce({ a: result('success', 15) })
balance.register(provider('a'))
await vi.advanceTimersByTimeAsync(80)
revision.value += 1
await nextTick()
await vi.advanceTimersByTimeAsync(80)
expect(api.batchQueryBalance).toHaveBeenCalledTimes(2)
expect(balance.getProviderBalance('a')?.available).toBe(15)
})
it('removes balances when ops becomes unconfigured during a request', async () => {
const { balance } = mountBalance()
let finish!: (results: Record<string, ActionResultResponse>) => void
api.batchQueryBalance.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
balance.register(provider('a'))
await vi.advanceTimersByTimeAsync(80)
balance.register(provider('a', false))
finish({ a: result('pending') })
await vi.advanceTimersByTimeAsync(80)
await vi.advanceTimersByTimeAsync(12_000)
expect(balance.balanceCache.value).toEqual({})
expect(api.batchQueryBalance).toHaveBeenCalledOnce()
})
it('cancels queued and pending work when the workspace unmounts', async () => {
const { balance } = mountBalance()
let finish!: (results: Record<string, ActionResultResponse>) => void
api.batchQueryBalance.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
balance.register(provider('a'))
await vi.advanceTimersByTimeAsync(80)
balance.register(provider('b'))
app?.unmount()
app = undefined
finish({ a: result('pending') })
await vi.advanceTimersByTimeAsync(20_000)
expect(api.batchQueryBalance).toHaveBeenCalledOnce()
expect(balance.balanceCache.value).toEqual({})
expect(vi.getTimerCount()).toBe(0)
balance.register(provider('c'))
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -1,5 +1,5 @@
import { computed, nextTick, onScopeDispose, ref, watch, type Ref } from 'vue'
import { useEventListener, useLocalStorage, useRafFn } from '@vueuse/core'
import { useEventListener, useRafFn } from '@vueuse/core'
import { useI18n } from '@/i18n'
interface SortableProvider {
@@ -16,13 +16,15 @@ interface ProviderPointerDrag {
scrollContainer: HTMLElement | null
}
export function useProviderDisplayOrder<Provider extends SortableProvider>(
export function useProviderPriorityOrder<Provider extends SortableProvider>(
providers: () => Provider[],
container: Ref<HTMLElement | null>,
scheduling: {
disabled: () => boolean
move: (providerId: string, targetId: string) => void
},
) {
const { legacyT } = useI18n()
const savedOrder = useLocalStorage<string[]>('aether-provider-display-order', [])
const knownOrder = ref<string[]>([])
const draggingProviderId = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)
const pointerPosition = ref({ clientX: 0, clientY: 0 })
@@ -30,18 +32,7 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
let pointerDrag: ProviderPointerDrag | null = null
let suppressClickUntil = 0
const normalizedOrder = computed(() => Array.isArray(savedOrder.value)
? [...new Set(savedOrder.value.filter((providerId): providerId is string => typeof providerId === 'string'))]
: [])
const hasCustomOrder = computed(() => normalizedOrder.value.length > 0)
const orderedProviders = computed(() => {
const ranks = new Map(normalizedOrder.value.map((providerId, index) => [providerId, index]))
return [...providers()].sort((first, second) => (
(ranks.get(first.id) ?? ranks.size) - (ranks.get(second.id) ?? ranks.size)
))
})
const orderedProviders = computed(providers)
const draggingProvider = computed(() => orderedProviders.value.find(provider => provider.id === draggingProviderId.value))
const dragPreviewStyle = computed(() => ({
@@ -50,18 +41,9 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
}))
function moveProvider(providerId: string, targetId: string) {
const visibleIds = orderedProviders.value.map(provider => provider.id)
const sourceIndex = visibleIds.indexOf(providerId)
const targetIndex = visibleIds.indexOf(targetId)
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return
visibleIds.splice(sourceIndex, 1)
visibleIds.splice(targetIndex, 0, providerId)
const visibleSet = new Set(visibleIds)
const allIds = [...new Set([...normalizedOrder.value, ...knownOrder.value, ...visibleIds])]
let visibleIndex = 0
savedOrder.value = allIds.map(currentId => visibleSet.has(currentId) ? visibleIds[visibleIndex++] ?? currentId : currentId)
announcement.value = `${legacyT('展示顺序已更新')}: ${orderedProviders.value[targetIndex]?.name} (${targetIndex + 1}/${visibleIds.length})`
if (scheduling.disabled()) return
scheduling.move(providerId, targetId)
announcement.value = legacyT('调度顺序已调整,保存后生效')
}
function updateDropTarget() {
@@ -119,6 +101,7 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
}
function startDrag(providerId: string, event: PointerEvent) {
if (scheduling.disabled()) return
if (event.button !== 0 || event.isPrimary === false || orderedProviders.value.length < 2) return
if (!orderedProviders.value.some(provider => provider.id === providerId)) return
const handle = event.currentTarget
@@ -194,10 +177,7 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
}
}
watch(() => providers().map(provider => provider.id), (providerIds) => {
knownOrder.value = [...new Set([...knownOrder.value, ...providerIds])]
cancelDrag()
}, { immediate: true })
watch(() => providers().map(provider => provider.id), cancelDrag, { immediate: true })
useEventListener(window, 'pointermove', handlePointerMove, { passive: false })
useEventListener(window, 'pointerup', handlePointerUp)
useEventListener(window, 'pointercancel', (event) => {
@@ -214,7 +194,6 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
return {
orderedProviders,
hasCustomOrder,
draggingProvider,
dragPreviewStyle,
announcement,
@@ -0,0 +1,74 @@
import { inject, onScopeDispose, provide, watch, type InjectionKey } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { useProviderBalance } from './useProviderBalance'
type BalanceProvider = Pick<ProviderWithEndpointsSummary, 'id' | 'ops_configured'>
type SchedulingBalance = ReturnType<typeof useProviderBalance> & {
register: (provider: BalanceProvider) => void
}
const schedulingBalanceKey: InjectionKey<SchedulingBalance> = Symbol('scheduling-provider-balance')
/** Share one balance request/cache across the model configurations in this workspace. */
export function provideSchedulingProviderBalance(revision?: () => number) {
const balance = useProviderBalance()
const providers = new Map<string, BalanceProvider>()
let timer: ReturnType<typeof setTimeout> | undefined
let refreshRequested = false
let loading = false
let disposed = false
async function flush() {
timer = undefined
if (loading || disposed || !refreshRequested) return
refreshRequested = false
loading = true
try {
// loadBalances invalidates older retries on each call. Include every registered
// provider so a newly mounted model panel cannot strand an older pending balance.
await balance.loadBalances([...providers.values()], false)
} finally {
loading = false
// A provider can lose its ops configuration while its request is in flight.
for (const id of Object.keys(balance.balanceCache.value)) {
if (!providers.has(id)) delete balance.balanceCache.value[id]
}
if (!disposed && refreshRequested) schedule()
}
}
function schedule() {
if (timer === undefined && !loading && !disposed) timer = setTimeout(() => { void flush() }, 80)
}
function register(provider: BalanceProvider) {
if (disposed) return
if (!provider.ops_configured) {
if (!providers.delete(provider.id)) return
delete balance.balanceCache.value[provider.id]
} else {
if (providers.has(provider.id)) return
providers.set(provider.id, { id: provider.id, ops_configured: true })
}
refreshRequested = true
schedule()
}
if (revision) {
watch(revision, () => {
if (providers.size === 0) return
refreshRequested = true
schedule()
})
}
onScopeDispose(() => {
disposed = true
if (timer !== undefined) clearTimeout(timer)
providers.clear()
})
provide(schedulingBalanceKey, { ...balance, register })
}
export function useSchedulingProviderBalance() {
return inject(schedulingBalanceKey, null)
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { createEmptyRoutingGroupConfig, getModelPolicy, setModelProviderPriorityOverrides } from '@/features/routing/utils/routingPolicy'
import { moveGroupProvider, providerGroupPriority, sortGroupProviders } from '../groupPriority'
const providers = [
{ id: 'a', name: 'A', provider_priority: 10 },
{ id: 'b', name: 'B', provider_priority: 20 },
{ id: 'c', name: 'C', provider_priority: 20 },
{ id: 'd', name: 'D', provider_priority: 30 },
]
describe('provider directory group priorities', () => {
it('uses model overrides, then group defaults, then resource priority', () => {
let config = setModelProviderPriorityOverrides(createEmptyRoutingGroupConfig(), '*', { a: 40 })
config = setModelProviderPriorityOverrides(config, 'model-x', { b: 5 })
expect(sortGroupProviders(config, providers).map(p => p.id)).toEqual(['b', 'c', 'd', 'a'])
expect(providers.map(p => providerGroupPriority(config, p, 'model-x'))).toEqual([40, 5, 20, 30])
expect(sortGroupProviders(createEmptyRoutingGroupConfig(), providers).map(p => p.id)).toEqual(['a', 'b', 'c', 'd'])
})
it('moves a provider across hidden rows while preserving untouched ties and saved missing-provider overrides', () => {
const config = setModelProviderPriorityOverrides(createEmptyRoutingGroupConfig(), '*', { missing: 100 })
const next = moveGroupProvider(config, providers, 'd', 'a')
expect(sortGroupProviders(next, providers).map(p => p.id)).toEqual(['d', 'a', 'b', 'c'])
const priorities = getModelPolicy(next, '*').provider_priority_overrides
expect(priorities.b).toBe(priorities.c)
expect(priorities.missing).toBe(100)
expect(getModelPolicy(config, '*').provider_priority_overrides).toEqual({ missing: 100 })
})
it('splits a moved provider out of a tied priority and isolates model-specific changes', () => {
const config = createEmptyRoutingGroupConfig()
const next = moveGroupProvider(config, providers, 'c', 'b', 'model-x')
expect(sortGroupProviders(next, providers, 'model-x').map(p => p.id)).toEqual(['a', 'c', 'b', 'd'])
expect(providerGroupPriority(next, providers[2], 'model-x')).toBeLessThan(providerGroupPriority(next, providers[1], 'model-x'))
expect(sortGroupProviders(next, providers).map(p => p.id)).toEqual(['a', 'b', 'c', 'd'])
})
})
@@ -0,0 +1,42 @@
import {
getModelPolicy,
setModelProviderPriorityOverrides,
type RoutingGroupConfig,
} from '@/features/routing/utils/routingPolicy'
interface RankedProvider { id: string; name: string; provider_priority: number }
export function providerGroupPriority(config: RoutingGroupConfig | null, provider: RankedProvider, model = '*'): number {
if (!config) return provider.provider_priority
return config.model_policies.find(policy => policy.model === model)?.provider_priority_overrides[provider.id]
?? config.model_policies.find(policy => policy.model === '*')?.provider_priority_overrides[provider.id]
?? provider.provider_priority
}
export function sortGroupProviders<T extends RankedProvider>(config: RoutingGroupConfig | null, providers: T[], model = '*'): T[] {
const priorities = new Map(providers.map(provider => [provider.id, providerGroupPriority(config, provider, model)]))
return [...providers].sort((a, b) => (priorities.get(a.id) ?? 0) - (priorities.get(b.id) ?? 0)
|| a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
}
// Move within the complete priority order, keeping hidden providers and untouched ties.
export function moveGroupProvider(config: RoutingGroupConfig, providers: RankedProvider[], providerId: string, targetId: string, model = '*'): RoutingGroupConfig {
const rows = sortGroupProviders(config, providers, model).map(provider => ({
id: provider.id, priority: providerGroupPriority(config, provider, model),
}))
const from = rows.findIndex(row => row.id === providerId)
const to = rows.findIndex(row => row.id === targetId)
if (from < 0 || to < 0 || from === to) return config
const [moving] = rows.splice(from, 1)
rows.splice(to, 0, moving)
let rank = 0
const overrides = { ...getModelPolicy(config, model).provider_priority_overrides }
rows.forEach((row, index) => {
const previous = rows[index - 1]
if (previous && (previous.priority !== row.priority || (previous.id === providerId) !== (row.id === providerId))) rank += 1
// A moved row starts its own priority bucket; untouched equal-priority
// rows remain tied so the backend's normal tie-break still applies.
overrides[row.id] = rank
})
return setModelProviderPriorityOverrides(config, model, overrides)
}
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import client from '@/api/client'
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import RoutingPriorityPolicyEditor from '../components/RoutingPriorityPolicyEditor.vue'
import { createEmptyRoutingGroupConfig, getDefaultModelPolicy, type RoutingGroupConfig } from '../utils/routingPolicy'
vi.mock('@/api/endpoints', () => ({ getProvidersSummary: vi.fn() }))
vi.mock('@/api/client', () => ({ default: { get: vi.fn() } }))
const providerSources = ['A', 'B', 'C', 'D'].map((name, index) => ({
id: name,
name: `提供商 ${name}`,
global_model_ids: index === 3 ? ['other-model'] : ['selected-model'],
provider_priority: [0, 1, 1, 2][index],
is_active: true,
active_keys: 2,
total_keys: 3,
avg_health_score: 0.9,
api_formats: ['openai:chat'],
pool_advanced: name === 'C' ? { global_priority: 1 } : null,
})) as ProviderWithEndpointsSummary[]
const mounted: Array<{ app: App, root: HTMLElement }> = []
function mountEditor(options: { modelIds?: string[], keyMode?: boolean } = {}) {
const initial = createEmptyRoutingGroupConfig()
if (options.keyMode) initial.default_policy.priority_mode = 'global_key'
initial.model_policies = [{
...getDefaultModelPolicy(initial),
provider_priority_overrides: { outside: 99 },
key_priority_overrides_by_format: { 'openai:chat': { outside: 99 }, 'claude:chat': { 'claude-key': 45 } },
pool_priority_overrides: { 'other-pool': 88 },
}]
const config = ref(initial)
const modelIds = ref(options.modelIds)
const revision = ref(0)
const inspect = vi.fn()
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup: () => () => h(RoutingPriorityPolicyEditor, {
config: config.value,
providerModelIds: modelIds.value,
refreshRevision: revision.value,
onInspectProvider: inspect,
'onUpdate:config': (value: RoutingGroupConfig) => { config.value = value },
}),
})
app.mount(root)
mounted.push({ app, root })
return { root, config, revision, inspect, modelIds }
}
function control<T extends HTMLElement>(root: HTMLElement, label: string): T {
const element = root.querySelector<T>(`[aria-label="${label}"]`)
expect(element, label).toBeTruthy()
return element!
}
function rowNames(root: HTMLElement): string[] {
return [...root.querySelectorAll('[draggable="true"] .font-medium')].map(element => element.textContent!.trim())
}
async function search(root: HTMLElement, value: string, keyMode = false) {
const input = control<HTMLInputElement>(root, keyMode ? '搜索调度 Key' : '搜索调度提供商')
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
async function click(root: HTMLElement, label: string) {
control<HTMLButtonElement>(root, label).click()
await nextTick()
}
beforeEach(() => {
vi.mocked(getProvidersSummary).mockReset()
vi.mocked(getProvidersSummary).mockResolvedValue({ items: providerSources, total: 4, page: 1, page_size: 9999 })
vi.mocked(client.get).mockReset()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() }
vi.unstubAllGlobals()
})
describe('RoutingPriorityPolicyEditor ordering', () => {
it('moves a searched provider through the full order while preserving untouched priority ties and hidden overrides', async () => {
const { root, config } = mountEditor()
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(4))
await search(root, '提供商 D')
expect(rowNames(root)).toEqual(['提供商 D'])
expect(control<HTMLButtonElement>(root, '置顶 提供商 D').disabled).toBe(false)
await click(root, '置顶 提供商 D')
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ outside: 99, D: 0, A: 1, B: 2, C: 2 })
await search(root, '')
expect(rowNames(root)).toEqual(['提供商 D', '提供商 A', '提供商 B', '提供商 C'])
await click(root, '置底 提供商 D')
expect(rowNames(root)).toEqual(['提供商 A', '提供商 B', '提供商 C', '提供商 D'])
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ outside: 99, A: 0, B: 1, C: 1, D: 2 })
})
it('uses actual positions for up/down controls in a filtered list', async () => {
const { root } = mountEditor()
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(4))
await search(root, '提供商 B')
await click(root, '下移 提供商 B')
await search(root, '')
expect(rowNames(root)).toEqual(['提供商 A', '提供商 C', '提供商 B', '提供商 D'])
await search(root, '提供商 B')
await click(root, '上移 提供商 B')
await search(root, '')
expect(rowNames(root)).toEqual(['提供商 A', '提供商 B', '提供商 C', '提供商 D'])
})
it('allows equal numeric priorities and retains overrides outside the model selection when reordered', async () => {
const { root, config } = mountEditor({ modelIds: ['selected-model'] })
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(3))
const input = control<HTMLInputElement>(root, '提供商 A 优先级')
input.value = '1'
input.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ outside: 99, A: 1 })
await click(root, '置顶 提供商 C')
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ outside: 99, C: 0, A: 1, B: 1 })
})
it('keeps the full selected group when search hides some selected providers', async () => {
const { root, config } = mountEditor()
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(4))
const selectButton = [...root.querySelectorAll('button')].find(button => button.textContent?.trim() === '多选')!
selectButton.click()
await nextTick()
await click(root, '选择 提供商 B')
await click(root, '选择 提供商 D')
await search(root, 'D')
await click(root, '置顶 提供商 D')
await search(root, '')
expect(rowNames(root)).toEqual(['提供商 B', '提供商 D', '提供商 A', '提供商 C'])
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ outside: 99, B: 0, D: 1, A: 2, C: 3 })
})
it('drags across filtered rows without losing hidden providers', async () => {
const { root, config } = mountEditor()
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(4))
await search(root, 'D')
root.querySelector('[draggable="true"]')!.dispatchEvent(new Event('dragstart', { bubbles: true }))
await search(root, 'A')
root.querySelector('[draggable="true"]')!.dispatchEvent(new Event('drop', { bubbles: true }))
await nextTick()
await search(root, '')
expect(rowNames(root)).toEqual(['提供商 D', '提供商 A', '提供商 B', '提供商 C'])
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ outside: 99, D: 0, A: 1, B: 2, C: 2 })
})
it('uses provider ordering for legacy Key configs without requesting keys or dropping their history', async () => {
const { root, config } = mountEditor({ keyMode: true, modelIds: ['selected-model'] })
await vi.waitFor(() => expect(rowNames(root)).toEqual(['提供商 A', '提供商 B', '提供商 C']))
expect(root.textContent).toContain('提供商排序')
expect(root.textContent).not.toContain('Key 排序')
expect(root.querySelector('[aria-label="搜索调度 Key"]')).toBeNull()
expect(vi.mocked(client.get)).not.toHaveBeenCalled()
const keyHistory = JSON.stringify(getDefaultModelPolicy(config.value).key_priority_overrides_by_format)
await click(root, '置顶 提供商 C')
expect(config.value.default_policy.priority_mode).toBe('provider')
expect(rowNames(root)).toEqual(['提供商 C', '提供商 A', '提供商 B'])
expect(JSON.stringify(getDefaultModelPolicy(config.value).key_priority_overrides_by_format)).toBe(keyHistory)
})
it('keeps model scope and group exclusions visible for legacy Key configurations', async () => {
const { root, config, modelIds } = mountEditor({ keyMode: true, modelIds: [] })
await vi.waitFor(() => expect(root.textContent).toContain('暂无 Provider'))
expect(rowNames(root)).toEqual([])
config.value.disabled_providers = ['A', 'C']
modelIds.value = ['selected-model']
await vi.waitFor(() => expect(rowNames(root)).toEqual(['提供商 A', '提供商 B', '提供商 C']))
const rows = [...root.querySelectorAll<HTMLElement>('[draggable="true"]')]
expect(rows.find(row => row.textContent?.includes('提供商 A'))?.textContent).toContain('本组禁用')
expect(rows.find(row => row.textContent?.includes('提供商 C'))?.textContent).toContain('本组禁用')
expect(rows.find(row => row.textContent?.includes('提供商 B'))?.textContent).not.toContain('本组禁用')
expect(rows.every(row => !row.textContent?.includes('停用'))).toBe(true)
})
it('emits provider inspection and refreshes health and keys without altering the draft', async () => {
const { root, config, revision, inspect } = mountEditor()
await vi.waitFor(() => expect(rowNames(root)).toHaveLength(4))
await click(root, '查看提供商 提供商 B')
expect(inspect).toHaveBeenCalledWith('B')
await click(root, '置顶 提供商 D')
const before = JSON.stringify(config.value)
vi.mocked(getProvidersSummary).mockResolvedValue({
items: providerSources.map(provider => ({ ...provider, active_keys: 0, avg_health_score: 0.5 })),
total: 4, page: 1, page_size: 9999,
})
revision.value += 1
await vi.waitFor(() => expect(root.textContent).toContain('可用 Key 0 / 3'))
expect(root.textContent).toContain('健康度 50%')
expect(JSON.stringify(config.value)).toBe(before)
})
})
@@ -35,18 +35,22 @@ function mountEditor(selectedModels?: string[], initialModels = globalModels) {
models: selectedModels,
}]))
const models = ref(initialModels)
const inspect = vi.fn()
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup: () => () => h(RoutingSchedulingPolicyEditor, {
config: config.value,
globalModels: models.value,
onInspectProvider: inspect,
'onUpdate:config': (value: RoutingGroupConfig) => { config.value = value },
}, {
'provider-status': ({ provider }: { provider: ProviderWithEndpointsSummary }) => h('span', { 'data-provider-status': provider.id }, '余额已连接'),
}),
})
app.mount(root)
mounted.push({ app, root })
return { root, config, models }
return { root, config, models, inspect }
}
function providerNames(root: HTMLElement): string[] {
@@ -93,6 +97,16 @@ afterEach(() => {
})
describe('scheduling provider filtering', () => {
it('makes shared-model edits explicit and forwards provider inspection and status slots', async () => {
const { root, inspect } = mountEditor(['model-a', 'model-b'])
await vi.waitFor(() => expect(providerNames(root)).toHaveLength(3))
expect(root.textContent).toContain('正在编辑 2 个模型共用的配置')
expect(root.textContent).toContain('model-a、model-b')
expect(root.querySelector('[data-provider-status="provider-shared"]')?.textContent).toBe('余额已连接')
await clickButton(root, '查看提供商 共享提供商')
expect(inspect).toHaveBeenCalledWith('provider-shared')
})
it('filters providers after choosing a global model and restores all-model mode', async () => {
const { root } = mountEditor()
await vi.waitFor(() => expect(providerNames(root)).toHaveLength(4))
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import { createApp, h, nextTick, ref, type App, type VNode } from 'vue'
import type { GlobalModelResponse } from '@/api/global-models'
import RoutingSchedulingPolicyEditor from '../components/RoutingSchedulingPolicyEditor.vue'
import {
@@ -8,6 +8,7 @@ import {
getModelPolicy,
getModelScheduling,
setDefaultProviderPriorityOverrides,
type RoutingModelPolicy,
type RoutingGroupConfig,
} from '../utils/routingPolicy'
import { createSchedulingPolicy, readSchedulingPolicies, writeSchedulingPolicies } from '../utils/schedulingPolicies'
@@ -25,13 +26,21 @@ vi.mock('../components/RoutingPriorityPolicyEditor.vue', () => ({
const mounted: Array<{ app: App, root: HTMLElement }> = []
function mountEditor(initial = createEmptyRoutingGroupConfig()) {
function mountEditor(
initial = createEmptyRoutingGroupConfig(),
layout: 'embedded' | 'config-only' = 'embedded',
toolbarSlots: Record<string, () => VNode> = {},
sidebar = false,
initialSelection?: { id?: string | null; scope: 'all' | 'selected'; modelNames: string[] },
) {
const config = ref(initial)
const valid = ref(true)
const disabled = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
const reload = vi.fn()
const selection = vi.fn()
const editor = ref<InstanceType<typeof RoutingSchedulingPolicyEditor> | null>(null)
const models = ref(['a', 'b', 'c'].map(name => ({
id: `id-${name}`, name: `model-${name}`, display_name: `模型 ${name.toUpperCase()}`,
})) as GlobalModelResponse[])
@@ -39,6 +48,10 @@ function mountEditor(initial = createEmptyRoutingGroupConfig()) {
document.body.appendChild(root)
const app = createApp({
setup: () => () => h(RoutingSchedulingPolicyEditor, {
ref: editor,
layout,
sidebar,
initialSelection,
config: config.value,
disabled: disabled.value,
globalModels: models.value,
@@ -47,11 +60,12 @@ function mountEditor(initial = createEmptyRoutingGroupConfig()) {
'onUpdate:config': (value: RoutingGroupConfig) => { config.value = value },
onValidityChange: (value: boolean) => { valid.value = value },
onReloadModels: reload,
}),
onSelectionChange: selection,
}, toolbarSlots),
})
app.mount(root)
mounted.push({ app, root })
return { root, config, valid, disabled, loading, error, models, reload }
return { root, config, valid, disabled, loading, error, models, reload, selection, editor }
}
function control<T extends HTMLElement>(root: HTMLElement, label: string): T {
@@ -106,6 +120,295 @@ afterEach(() => {
})
describe('RoutingSchedulingPolicyEditor', () => {
it('opens legacy Key configurations as provider scheduling without exposing a mode switch', async () => {
const initial = createEmptyRoutingGroupConfig()
initial.default_policy.priority_mode = 'global_key'
initial.model_policies = [{ ...getDefaultModelPolicy(initial), key_priority_overrides: { legacy: 3 } }]
const { root, config, selection } = mountEditor(initial)
expect(root.querySelector('[aria-label="调度优先级"]')).toBeNull()
expect([...root.querySelectorAll('button')].some(button => button.textContent?.trim() === 'Key')).toBe(false)
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ priorityMode: 'provider' }))
await clickText(root, '固定顺序')
expect(config.value.default_policy.priority_mode).toBe('provider')
expect(getDefaultModelPolicy(config.value).key_priority_overrides).toEqual({ legacy: 3 })
})
it('shows configuration without an inline ranking list and emits its all-model selection', async () => {
const { root, selection, config, editor } = mountEditor(createEmptyRoutingGroupConfig(), 'config-only')
expect(root.querySelector('[aria-label="调整排序"]')).toBeNull()
expect(control(root, '调度范围')).toBeTruthy()
expect(root.textContent).toContain('全局配置')
expect(root.textContent).not.toContain('调度设置')
expect(root.querySelector('h3')).toBeNull()
const strategy = control(root, '调度策略')
const scope = control(root, '调度范围')
expect(scope.compareDocumentPosition(strategy) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({
policy: expect.objectContaining({ provider_priority_overrides: {} }),
scope: 'all', modelNames: [], priorityMode: 'provider', schedulingMode: 'cache_affinity',
}))
const policy: RoutingModelPolicy = { ...getDefaultModelPolicy(config.value), provider_priority_overrides: { provider: 4 } }
editor.value!.updateSelectedPolicy(policy)
await flush()
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ provider: 4 })
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ policy: expect.objectContaining({ provider_priority_overrides: { provider: 4 } }) }))
})
it.each([false, true])('composes group and save slots without duplicating or mixing actions (sidebar: %s)', async sidebar => {
const saved = vi.fn()
const initial = createEmptyRoutingGroupConfig()
const { root, config } = mountEditor(initial, 'config-only', {
'toolbar-leading': () => h('label', [
'策略分组', h('select', { 'aria-label': '选择策略分组' }, [h('option', '默认分组')]),
]),
'toolbar-actions': () => h('button', { onClick: saved, 'aria-label': '保存分组配置' }, '保存'),
}, sidebar)
const leading = control(root, '选择策略分组')
const strategy = control(root, '调度策略')
const scope = control(root, '调度范围')
const save = control<HTMLButtonElement>(root, '保存分组配置')
expect(leading.compareDocumentPosition(scope) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(scope.compareDocumentPosition(strategy) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(scope.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(root.querySelectorAll('[aria-label="保存分组配置"]')).toHaveLength(1)
await clickText(root, '固定顺序')
expect(config.value.default_policy.scheduling_mode).toBe('fixed_order')
expect(saved).not.toHaveBeenCalled()
if (sidebar) {
await select(root, 'model-a')
const modelEditor = control(root, '当前配置的适用模型')
expect(modelEditor.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(root.querySelectorAll('[aria-label="保存分组配置"]')).toHaveLength(1)
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
}
save.click()
expect(saved).toHaveBeenCalledOnce()
})
it('keeps strategy explanations beside the control and updates them with the current selection', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only')
expect(control(root, '调度策略说明').getAttribute('title')).toContain('固定顺序:')
control<HTMLButtonElement>(root, '选择调度配置 2').click()
await flush()
expect(control(root, '调度策略说明').getAttribute('title')).toContain('负载均衡:')
expect(control(root, '选择调度配置 2').textContent).toContain('模型 B')
await clickText(root, '缓存亲和')
expect(control(root, '调度策略说明').getAttribute('title')).toContain('缓存亲和:')
expect(control(root, '选择调度配置 2').textContent).toContain('缓存亲和')
expect(control(root, '选择调度配置 1').textContent).toContain('固定顺序')
})
it('selects a legacy default entry without showing a model picker or changing explicit model policies', async () => {
const initial = createEmptyRoutingGroupConfig()
const selected = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const fallback = { ...createSchedulingPolicy(initial, 'all'), schedulingMode: 'cache_affinity' as const }
const { root, config, selection } = mountEditor(writeSchedulingPolicies(initial, [selected, fallback]), 'config-only')
control<HTMLButtonElement>(root, '选择调度配置 2').click()
await flush()
expect(root.querySelector('[aria-label="选择适用模型"]')).toBeNull()
expect(control(root, '当前配置的适用模型').textContent).toContain('未单独指定的模型')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'all', modelNames: [], schedulingMode: 'cache_affinity' }))
await clickText(root, '负载均衡')
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
control<HTMLButtonElement>(root, '选择调度配置 1').click()
await flush()
expect(root.querySelectorAll('[aria-label="选择适用模型"]')).toHaveLength(1)
expect(control(root, '选择适用模型').textContent).toContain('模型 A')
})
it('expands only the selected model editor and keeps every shared ranking attached to its configuration', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a', 'model-c'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root, config, selection, editor } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only')
expect(root.querySelectorAll('[aria-label="选择适用模型"]')).toHaveLength(1)
expect(root.querySelector('button[aria-label="选择适用模型"]')).toBeNull()
expect(control(root, '调度配置 1').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(control(root, '调度配置 2').querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(root.querySelector('[aria-label="调整排序"]')).toBeNull()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: first.id, modelNames: ['model-a', 'model-c'], schedulingMode: 'fixed_order' }))
control<HTMLButtonElement>(root, '选择调度配置 2').click()
await flush()
expect(control(root, '调度配置 2').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(control(root, '调度配置 1').querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'], schedulingMode: 'load_balance' }))
const policy = { ...getDefaultModelPolicy(initial), provider_priority_overrides: { provider: 6 } }
editor.value!.updateSelectedPolicy(policy)
await flush()
expect(getModelPolicy(config.value, 'model-b').provider_priority_overrides).toEqual({ provider: 6 })
expect(getModelPolicy(config.value, 'model-a').provider_priority_overrides).toEqual({})
control<HTMLButtonElement>(root, '选择调度配置 1').click()
await flush()
editor.value!.updateSelectedPolicy({ ...policy, provider_priority_overrides: { shared: 3 } })
await flush()
expect(getModelPolicy(config.value, 'model-a').provider_priority_overrides).toEqual({ shared: 3 })
expect(getModelPolicy(config.value, 'model-c').provider_priority_overrides).toEqual({ shared: 3 })
expect(root.querySelectorAll('[aria-label="选择适用模型"]')).toHaveLength(1)
control<HTMLButtonElement>(root, '删除调度配置 1').click()
await flush()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'] }))
})
it('reports an unfinished model selection as unavailable for directory priority edits', async () => {
const { root, selection, editor, config } = mountEditor(createEmptyRoutingGroupConfig(), 'config-only')
await clickText(root, '区分模型')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ policy: null, scope: 'selected', modelNames: [] }))
editor.value!.updateSelectedPolicy({ ...getDefaultModelPolicy(config.value), provider_priority_overrides: { unexpected: 7 } })
await flush()
expect(config.value.model_policies.some(policy => policy.provider_priority_overrides.unexpected === 7)).toBe(false)
await select(root, 'model-a')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ policy: expect.any(Object), scope: 'selected', modelNames: ['model-a'] }))
})
it('collapses the selected card without changing the directory target and expands switched or new configurations', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root, selection, config, editor } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only')
const firstButton = control<HTMLButtonElement>(root, '选择调度配置 1')
expect(firstButton.getAttribute('aria-expanded')).toBe('true')
selection.mockClear()
firstButton.click()
await flush()
expect(firstButton.getAttribute('aria-expanded')).toBe('false')
expect(firstButton.getAttribute('aria-pressed')).toBe('true')
expect(root.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(root.querySelector('[aria-label="调度策略"]')).toBeNull()
expect(selection).not.toHaveBeenCalled()
editor.value!.updateSelectedPolicy({ ...getDefaultModelPolicy(initial), provider_priority_overrides: { provider: 8 } })
await flush()
expect(getModelPolicy(config.value, 'model-a').provider_priority_overrides).toEqual({ provider: 8 })
expect(getModelPolicy(config.value, 'model-b').provider_priority_overrides).toEqual({})
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
expect(root.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(root.querySelector('[aria-label="调度策略"]')).toBeNull()
firstButton.click()
await flush()
expect(firstButton.getAttribute('aria-expanded')).toBe('true')
expect(control(root, '调度配置 1').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(control(root, '调度配置 1').contains(control(root, '调度策略'))).toBe(true)
firstButton.click()
await flush()
control<HTMLButtonElement>(root, '选择调度配置 2').click()
await flush()
expect(control(root, '选择调度配置 2').getAttribute('aria-expanded')).toBe('true')
expect(control(root, '选择调度配置 2').getAttribute('aria-pressed')).toBe('true')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'] }))
control<HTMLButtonElement>(root, '添加调度配置').click()
await flush()
expect(control(root, '选择调度配置 3').getAttribute('aria-expanded')).toBe('true')
expect(control(root, '选择调度配置 3').getAttribute('aria-pressed')).toBe('true')
expect(control(root, '调度配置 3').contains(control(root, '搜索全局模型'))).toBe(true)
expect(root.querySelectorAll('[aria-label="全局模型选择列表"]')).toHaveLength(1)
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ policy: null, modelNames: [] }))
})
it('restores the chosen configuration after remount, including regenerated legacy default IDs', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b', 'model-c'], schedulingMode: 'load_balance' as const }
const saved = writeSchedulingPolicies(initial, [first, second])
const { root, selection } = mountEditor(saved, 'config-only', {}, false, {
id: 'previous-generated-id', scope: 'selected', modelNames: ['model-c', 'model-b'],
})
expect(control(root, '选择调度配置 2').getAttribute('aria-pressed')).toBe('true')
expect(control(root, '调度配置 2').contains(control(root, '全局模型选择列表'))).toBe(true)
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ modelNames: ['model-b', 'model-c'] }))
const legacy = writeSchedulingPolicies(initial, [first, createSchedulingPolicy(initial, 'all')])
const restored = mountEditor(legacy, 'config-only', {}, false, { id: 'old-default-id', scope: 'all', modelNames: [] })
expect(control(restored.root, '选择调度配置 2').getAttribute('aria-pressed')).toBe('true')
expect(control(restored.root, '当前配置的适用模型').textContent).toContain('未单独指定的模型')
expect(restored.selection).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'all', modelNames: [] }))
})
it('edits scheduling after model selection inside the selected card without changing other configurations', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root, selection, config } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only')
const firstCard = control<HTMLElement>(root, '调度配置 1')
const secondCard = control<HTMLElement>(root, '调度配置 2')
expect(firstCard.contains(control(root, '调度策略'))).toBe(true)
expect(secondCard.querySelector('[aria-label="调度策略"]')).toBeNull()
expect(control(firstCard, '全局模型选择列表').compareDocumentPosition(control(firstCard, '调度策略')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
control<HTMLButtonElement>(secondCard, '选择调度配置 2').click()
await flush()
expect(firstCard.querySelector('[aria-label="调度策略"]')).toBeNull()
const secondStrategy = control(secondCard, '调度策略')
expect(control(secondCard, '全局模型选择列表').compareDocumentPosition(secondStrategy) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect([...secondStrategy.querySelectorAll('button')].find(button => button.textContent?.trim() === '负载均衡')?.getAttribute('aria-pressed')).toBe('true')
await clickText(secondStrategy, '缓存亲和')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b'], priorityMode: 'provider' }))
control<HTMLButtonElement>(firstCard, '选择调度配置 1').click()
await flush()
await clickText(control(firstCard, '调度策略'), '负载均衡')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: first.id, modelNames: ['model-a'], schedulingMode: 'load_balance' }))
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('cache_affinity')
control<HTMLButtonElement>(secondCard, '选择调度配置 2').click()
await flush()
control<HTMLInputElement>(root, '选择模型 model-c').click()
await flush()
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id, modelNames: ['model-b', 'model-c'] }))
})
it('searches and adds models inside the active card without carrying its search or edits to another configuration', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root, config } = mountEditor(writeSchedulingPolicies(initial, [first, second]), 'config-only', {}, true)
const firstCard = control(root, '调度配置 1')
const search = control<HTMLInputElement>(firstCard, '搜索全局模型')
search.value = '模型 C'
search.dispatchEvent(new Event('input', { bubbles: true }))
await flush()
control<HTMLInputElement>(firstCard, '选择模型 model-c').click()
await flush()
expect(getModelScheduling(config.value, 'model-c').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
control<HTMLButtonElement>(root, '选择调度配置 2').click()
await flush()
expect(root.querySelectorAll('[aria-label="搜索全局模型"]')).toHaveLength(1)
const nextSearch = control<HTMLInputElement>(root, '搜索全局模型')
expect(nextSearch.value).toBe('')
expect(control<HTMLInputElement>(root, '选择模型 model-b').checked).toBe(true)
nextSearch.value = 'model-c'
nextSearch.dispatchEvent(new Event('input', { bubbles: true }))
await flush()
expect(control<HTMLInputElement>(root, '选择模型 model-c').disabled).toBe(true)
expect(control(root, '全局模型选择列表').textContent).toContain('已用于配置 1')
nextSearch.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flush()
expect(root.querySelector('[aria-label="全局模型选择列表"]')).not.toBeNull()
const add = control<HTMLButtonElement>(root, '添加调度配置')
for (const card of root.querySelectorAll('section[aria-label^="调度配置 "]')) {
expect(card.compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
expect(root.querySelector('[aria-label="完成选择"]')).toBeNull()
})
it('keeps a strategy chosen before model selection and lets unfinished configurations choose their strategy', async () => {
const { root, config, selection } = mountEditor(createEmptyRoutingGroupConfig(), 'config-only')
await clickText(root, '固定顺序')
await clickText(root, '区分模型')
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ policy: null, schedulingMode: 'fixed_order' }))
await select(root, 'model-a')
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
control<HTMLButtonElement>(root, '添加调度配置').click()
await flush()
await clickText(root, '负载均衡')
control<HTMLInputElement>(root, '选择模型 model-b').click()
await flush()
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
expect(root.querySelectorAll('[aria-label="调度策略"]')).toHaveLength(1)
})
it('starts with one all-model configuration and no model picker or add control', async () => {
const { root, config, valid } = mountEditor()
expect(control(root, '调度范围').getAttribute('role')).toBe('group')
@@ -146,14 +449,13 @@ describe('RoutingSchedulingPolicyEditor', () => {
expect(picker.getAttribute('aria-expanded')).toBe('false')
expect(root.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(document.activeElement).toBe(picker)
await clickText(root, 'Key')
await clickText(root, '固定顺序')
control<HTMLButtonElement>(root, '调整排序').click()
await nextTick()
expect(valid.value).toBe(true)
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
for (const model of ['model-a', 'model-b']) {
expect(getModelScheduling(config.value, model)).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'fixed_order' })
expect(getModelScheduling(config.value, model)).toMatchObject({ priority_mode: 'provider', scheduling_mode: 'fixed_order' })
expect(getModelPolicy(config.value, model).provider_priority_overrides).toEqual({ provider: 7 })
}
expect(getModelScheduling(config.value, 'model-c')).toMatchObject({ priority_mode: 'provider', scheduling_mode: 'cache_affinity' })
@@ -318,7 +620,6 @@ describe('RoutingSchedulingPolicyEditor', () => {
it('inherits all-model settings on first entering model-specific mode and restores both drafts', async () => {
const { root, config, valid } = mountEditor()
await clickText(root, '负载均衡')
await clickText(root, 'Key')
control<HTMLButtonElement>(root, '调整排序').click()
await flush()
await clickText(root, '区分模型')
@@ -326,7 +627,7 @@ describe('RoutingSchedulingPolicyEditor', () => {
expect(control(root, '选择适用模型').textContent).toContain('请选择全局模型')
await select(root, 'model-a')
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
expect(getModelScheduling(config.value, 'model-a')).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'load_balance' })
expect(getModelScheduling(config.value, 'model-a')).toMatchObject({ priority_mode: 'provider', scheduling_mode: 'load_balance' })
expect(getModelPolicy(config.value, 'model-a').provider_priority_overrides).toEqual({ provider: 7 })
expect(getModelScheduling(config.value, 'new-model').scheduling_mode).toBe('cache_affinity')
await clickText(root, '固定顺序')
@@ -14,6 +14,7 @@ import {
} from '../utils/routingPolicy'
import {
createSchedulingPolicy,
normalizeProviderSchedulingConfig,
readSchedulingPolicies,
schedulingPolicyEditorConfig,
validateSchedulingPolicies,
@@ -21,6 +22,34 @@ import {
} from '../utils/schedulingPolicies'
describe('strategy-scoped scheduling policies', () => {
it('normalizes legacy Key scheduling without mutating unrelated rules, scopes, or historical priorities', () => {
const config = createEmptyRoutingGroupConfig()
config.default_policy.priority_mode = 'global_key'
config.disabled_providers = ['disabled']
config.model_policies = [{ ...createEmptyModelPolicy('*'), key_priority_overrides: { key: 5 }, provider_priority_overrides: { provider: 3 } }]
config.rules = [{
id: 'custom-rule', priority: 1, enabled: true, phase: 'client_request', stop_processing: true,
conditions: { field: 'model', op: 'prefix', value: 'example' },
actions: [
{ type: 'set_scheduling', priority_mode: 'global_key', scheduling_mode: 'load_balance', sticky_key_attempts: 4 },
{ type: 'set_scheduling', scheduling_mode: 'fixed_order' },
{ type: 'set_header', name: 'x-test', value: 'kept' },
],
}]
const before = JSON.stringify(config)
const normalized = normalizeProviderSchedulingConfig(config)
expect(normalized.default_policy.priority_mode).toBe('provider')
expect(normalized.disabled_providers).toEqual(config.disabled_providers)
expect(normalized.model_policies).toEqual(config.model_policies)
expect(normalized.rules[0]).toEqual({ ...config.rules[0], actions: [
{ type: 'set_scheduling', priority_mode: 'provider', scheduling_mode: 'load_balance', sticky_key_attempts: 4 },
...config.rules[0].actions.slice(1),
] })
expect(readSchedulingPolicies(config).every(entry => entry.priorityMode === 'provider')).toBe(true)
expect(createSchedulingPolicy(config).priorityMode).toBe('provider')
expect(JSON.stringify(config)).toBe(before)
})
it('starts with one all-model strategy', () => {
const entries = readSchedulingPolicies(createEmptyRoutingGroupConfig())
expect(entries).toHaveLength(1)
@@ -52,7 +81,7 @@ describe('strategy-scoped scheduling policies', () => {
expect(readSchedulingPolicies(saved)[0]).toMatchObject({ scope: 'all', models: [] })
expect(getDefaultModelPolicy(saved).provider_priority_overrides).toEqual({ provider: 2 })
for (const model of ['model-a', 'model-b', 'future-model']) {
expect(getModelScheduling(saved, model)).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'load_balance' })
expect(getModelScheduling(saved, model)).toMatchObject({ priority_mode: 'provider', scheduling_mode: 'load_balance' })
}
})
@@ -77,7 +106,7 @@ describe('strategy-scoped scheduling policies', () => {
{ field: 'model', op: 'eq', value: 'model-b' },
] })
for (const model of entry.models) {
expect(getModelScheduling(saved, model)).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'fixed_order' })
expect(getModelScheduling(saved, model)).toMatchObject({ priority_mode: 'provider', scheduling_mode: 'fixed_order' })
expect(getModelPolicy(saved, model)).toEqual({ ...entry.policy, model })
}
expect(getModelScheduling(saved, 'other-model').scheduling_mode).toBe('cache_affinity')
@@ -87,6 +116,16 @@ describe('strategy-scoped scheduling policies', () => {
expect(schedulingPolicyEditorConfig(saved, reloaded[0]).model_policies).toEqual([entry.policy])
})
it('retains group exclusions in the isolated ranking editor config', () => {
const config = createEmptyRoutingGroupConfig()
config.disabled_providers = ['disabled-provider']
const entry = createSchedulingPolicy(config)
const editorConfig = schedulingPolicyEditorConfig(config, entry)
expect(editorConfig.disabled_providers).toEqual(['disabled-provider'])
editorConfig.disabled_providers.push('local-only')
expect(config.disabled_providers).toEqual(['disabled-provider'])
})
it('retains separate strategies even when their settings are identical', () => {
const config = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(config), models: ['model-a'] }
@@ -108,7 +147,7 @@ describe('strategy-scoped scheduling policies', () => {
expect(entries[1].models).toEqual(['model-c'])
const saved = writeSchedulingPolicies(config, entries)
for (const model of ['model-a', 'model-b', 'model-c', 'other']) {
expect(getModelScheduling(saved, model)).toEqual(getModelScheduling(config, model))
expect(getModelScheduling(saved, model)).toEqual({ ...getModelScheduling(config, model), priority_mode: 'provider' })
expect(getModelPolicy(saved, model)).toEqual(getModelPolicy(config, model))
}
expect(saved.rules.every(rule => !rule.id.startsWith('ui_model_scheduling:'))).toBe(true)
@@ -1,18 +1,22 @@
<template>
<section
class="space-y-4 rounded-lg border border-border/60 p-4"
class="min-w-0 space-y-4"
:class="{ 'rounded-lg border border-border/60 p-4': !sidebar }"
data-testid="routing-failover-policy"
>
<div>
<h3 class="text-sm font-medium">
故障转移规则
</h3>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
作用于当前调度策略的所有提供商。先检查全局规则,再检查提供商自身规则;业务内容输出后不再重放请求。
</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="space-y-1.5 text-sm">
<div
class="grid min-w-0 gap-3"
:class="sidebar ? 'grid-cols-2' : 'grid-cols-1 md:grid-cols-2'"
>
<label
class="min-w-0 space-y-1.5"
:class="sidebar ? 'text-xs' : 'text-sm'"
>
<span>全局最大转移次数</span>
<Input
:model-value="modelValue.max_transfer_count"
@@ -23,9 +27,11 @@
aria-label="全局最大转移次数"
@update:model-value="updateLimit('max_transfer_count', $event)"
/>
<span class="block text-xs leading-relaxed text-muted-foreground">0 不限制。首次尝试和粘性同 Key 重试不计入;每次切换候选计 1 次。</span>
</label>
<label class="space-y-1.5 text-sm">
<label
class="min-w-0 space-y-1.5"
:class="sidebar ? 'text-xs' : 'text-sm'"
>
<span>全局最大转移时间(秒)</span>
<Input
:model-value="modelValue.max_transfer_timeout_seconds"
@@ -36,8 +42,8 @@
aria-label="全局最大转移时间"
@update:model-value="updateLimit('max_transfer_timeout_seconds', $event)"
/>
<span class="block text-xs leading-relaxed text-muted-foreground">0 不限制。从首次尝试累计,耗尽后不再启动下一次尝试;不会中断已开始的调用,单次超时仍独立生效。</span>
</label>
<slot name="limits-extra" />
</div>
<div
v-for="section in ruleSections"
@@ -49,9 +55,6 @@
<h4 class="text-sm font-medium">
{{ section.title }}
</h4>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
{{ section.description }}
</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button
@@ -129,14 +132,15 @@
v-for="(rule, index) in modelValue.failover_rules[section.key]"
:key="index"
class="grid min-w-0 grid-cols-[minmax(0,1fr)_2rem] items-start gap-2"
:class="section.key === 'error_stop_patterns' ? 'sm:grid-cols-[10rem_minmax(0,1fr)_2rem]' : ''"
:class="section.key === 'error_stop_patterns' && !sidebar ? 'sm:grid-cols-[10rem_minmax(0,1fr)_2rem]' : ''"
>
<Input
v-if="section.key === 'error_stop_patterns'"
:model-value="statusDrafts[index] ?? rule.status_codes.join(', ')"
:disabled="disabled"
size="sm"
class="col-span-2 min-w-0 w-full font-mono text-xs sm:col-span-1"
class="col-span-2 min-w-0 w-full font-mono text-xs"
:class="{ 'sm:col-span-1': !sidebar }"
:aria-label="`终止规则 ${index + 1} 状态码`"
placeholder="400, 413(选填)"
title="状态码用逗号或空格分隔;留空则匹配全部错误状态"
@@ -165,9 +169,6 @@
</div>
</template>
</div>
<p class="text-xs text-muted-foreground">
正则支持 (?i) 忽略大小写;服务端在保存时校验语法。每组最多 64 条,每条正则最多 4096 字节。
</p>
<p
v-if="validationError"
role="alert"
@@ -191,7 +192,7 @@ import {
type RoutingFailoverRules,
} from '../utils/routingFailover'
const props = defineProps<{ modelValue: RoutingFailoverPolicy, disabled?: boolean }>()
const props = defineProps<{ modelValue: RoutingFailoverPolicy, disabled?: boolean, sidebar?: boolean }>()
const emit = defineEmits<{
'update:modelValue': [value: RoutingFailoverPolicy]
'pending-change': [value: boolean]
@@ -214,9 +215,9 @@ const jsonDirty = reactive<Record<RuleSection, boolean>>({
error_stop_patterns: false,
})
const statusDrafts = ref<Record<number, string>>({})
const ruleSections: Array<{ key: RuleSection, title: string, description: string }> = [
{ key: 'success_failover_patterns', title: '成功转移规则', description: 'HTTP 200 的响应体或流式输出前的缓冲内容命中正则时,放弃当前候选并继续转移;不是对所有 200 都重试。' },
{ key: 'error_stop_patterns', title: '错误终止规则', description: '状态码与正则同时满足时立即终止。可只填状态码,或只填正则匹配全部 400–599 错误;对流内错误使用解析后的错误状态。' },
const ruleSections: Array<{ key: RuleSection, title: string }> = [
{ key: 'success_failover_patterns', title: '成功转移规则' },
{ key: 'error_stop_patterns', title: '错误终止规则' },
]
const validationError = computed(() => {
const rules = errorRulesFromForm()
@@ -1,11 +1,21 @@
<template>
<div class="min-w-0 space-y-2">
<div
class="min-w-0"
:role="inline ? 'group' : undefined"
:aria-label="inline ? '选择适用模型' : undefined"
:class="compact ? 'space-y-1' : 'space-y-2'"
>
<div class="min-w-0 space-y-2">
<div class="overflow-hidden rounded-lg border border-border/60 bg-background">
<div
class="overflow-hidden bg-background"
:class="inline ? '' : 'rounded-lg border border-border/60'"
>
<button
v-if="!inline"
ref="trigger"
type="button"
class="flex min-h-10 w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm font-normal text-foreground transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
class="flex w-full items-center justify-between gap-2 px-3 text-left text-sm font-normal text-foreground transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
:class="compact ? 'min-h-8 py-1' : 'min-h-10 py-2'"
:disabled="disabled"
aria-label="选择适用模型"
:aria-expanded="open"
@@ -24,20 +34,25 @@
/>
</button>
<div
v-if="open"
v-if="inline || open"
:id="listId"
class="flex min-w-0 flex-col border-t border-border/60"
class="flex min-w-0 flex-col"
:class="inline ? '' : 'border-t border-border/60'"
role="region"
aria-label="全局模型选择列表"
@keydown.esc.stop.prevent="closeModels"
>
<div class="relative shrink-0 p-2">
<div
class="relative shrink-0 px-2"
:class="compact ? 'py-1.5' : 'py-2'"
>
<Search class="pointer-events-none absolute left-5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref="searchInput"
v-model="search"
size="sm"
class="h-9 rounded-md border-border/60 bg-background pl-9 pr-3 text-sm"
class="rounded-md border-border/60 bg-background pl-9 pr-3 text-sm"
:class="compact ? 'h-8' : 'h-9'"
placeholder="搜索模型名称"
aria-label="搜索全局模型"
:disabled="disabled"
@@ -84,12 +99,19 @@
{{ allResultsSelected ? '取消当前选择' : search.trim() ? '全选结果' : '全选当前' }}
</Button>
</div>
<div class="grid max-h-64 min-h-0 grid-cols-1 gap-1 overflow-y-auto overscroll-contain p-2 sm:grid-cols-2">
<div
class="grid min-h-0 grid-cols-1 gap-1 overflow-y-auto overscroll-contain px-2"
:class="[
compact ? 'max-h-48 py-1' : 'max-h-64 py-2',
narrow ? '' : compact ? 'sm:grid-cols-2 xl:grid-cols-3' : 'sm:grid-cols-2',
]"
>
<label
v-for="model in filteredModels"
:key="model.name"
class="flex min-w-0 items-center gap-3 rounded-md px-2 py-2 text-sm"
class="flex min-w-0 items-center gap-3 rounded-md px-2 text-sm"
:class="[
compact ? 'py-1.5' : 'py-2',
model.owner ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-muted/50',
selectedModels.includes(model.name) ? 'bg-accent/60' : '',
]"
@@ -129,8 +151,12 @@
</div>
</template>
<div class="flex shrink-0 flex-wrap items-center gap-1 border-t border-border/60 p-2">
<span class="flex-1 text-xs text-muted-foreground">
<div
v-if="!inline"
class="flex shrink-0 flex-wrap items-center gap-1 border-t border-border/60 px-2"
:class="compact ? 'py-1' : 'py-2'"
>
<span class="flex-1 whitespace-nowrap text-xs text-muted-foreground">
已选 {{ selectedModels.length }} 个
</span>
<Button
@@ -142,7 +168,7 @@
aria-label="清空已选"
@click="updateModels([])"
>
清空已选
{{ narrow ? '清空' : '清空已选' }}
</Button>
<Button
type="button"
@@ -150,17 +176,18 @@
size="sm"
class="h-7 gap-1 px-2 text-xs font-medium"
:disabled="disabled"
aria-label="完成选择"
@click="closeModels"
>
<Check class="h-3.5 w-3.5" />
完成选择
{{ narrow ? '完成' : '完成选择' }}
</Button>
</div>
</div>
</div>
</div>
<p
v-if="modelValue.length === 0"
v-if="modelValue.length === 0 && !inline"
class="text-xs text-muted-foreground"
>
支持多选,选中的模型共用一套调度设置。
@@ -181,6 +208,10 @@ const props = defineProps<{
loading?: boolean
error?: string | null
disabled?: boolean
compact?: boolean
narrow?: boolean
/** Keep the search and model checklist mounted inside the parent card. */
inline?: boolean
}>()
const emit = defineEmits<{
@@ -228,6 +259,7 @@ async function openModels(): Promise<void> {
}
function closeModels(): void {
if (props.inline) return
open.value = false
trigger.value?.focus({ preventScroll: true })
}
@@ -1,60 +1,23 @@
<template>
<section class="space-y-4">
<div
v-if="showPriorityMode || showSchedulingMode"
class="grid gap-3"
:class="showPriorityMode ? 'lg:grid-cols-[1fr_1.4fr]' : ''"
v-if="showSchedulingMode"
class="space-y-1 text-sm"
>
<div
v-if="showPriorityMode"
class="space-y-1 text-sm"
>
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="effectivePriorityMode === 'provider'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updatePriorityMode('provider')"
>
<Layers class="h-4 w-4" />
Provider
</button>
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="effectivePriorityMode === 'global_key'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updatePriorityMode('global_key')"
>
<Key class="h-4 w-4" />
Key
</button>
</div>
</div>
<div
v-if="showSchedulingMode"
class="space-y-1 text-sm"
>
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="effectiveSchedulingMode === mode.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateSchedulingMode(mode.value)"
>
{{ mode.label }}
</button>
</div>
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="effectiveSchedulingMode === mode.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateSchedulingMode(mode.value)"
>
{{ mode.label }}
</button>
</div>
</div>
@@ -62,7 +25,7 @@
<div class="flex flex-col gap-3 border-b border-border/60 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="text-sm font-medium">
{{ effectivePriorityMode === 'provider' ? '提供商排序' : 'Key 排序' }}
提供商排序
</h3>
<p class="mt-1 text-xs text-muted-foreground">
{{ subtitle }}
@@ -70,7 +33,6 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="effectivePriorityMode === 'provider'"
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-xs font-medium transition-colors"
:class="providerMultiSelectEnabled
@@ -80,28 +42,40 @@
>
<ListChecks class="h-3.5 w-3.5" />
{{ providerMultiSelectEnabled ? '退出多选' : '多选' }}
<span v-if="providerMultiSelectEnabled && selectedProviderIds.size">{{ selectedProviderIds.size }}</span>
</button>
<Select
v-if="effectivePriorityMode === 'global_key'"
v-model="selectedApiFormat"
>
<SelectTrigger class="h-8 w-[180px] rounded-lg border-border/60 bg-background/80 px-3 text-xs">
<SelectValue placeholder="选择端点" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in apiFormats"
:key="format"
:value="format"
>
{{ formatLabel(format) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="min-h-[180px] max-h-[420px] overflow-y-auto p-3">
<div class="space-y-3 border-b border-border/60 bg-muted/15 px-4 py-3">
<p class="text-xs leading-relaxed text-muted-foreground">
{{ schedulingDescription }}
优先级数字越小越先,可填写相同数字。移动或拖动会重新编号,未拆开的同级组保留。
</p>
<div class="flex flex-wrap items-center gap-3">
<label class="relative min-w-0 flex-1 sm:max-w-sm">
<Search class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<input
v-model="searchQuery"
type="search"
aria-label="搜索调度提供商"
placeholder="搜索提供商名称"
class="h-9 w-full rounded-md border border-border bg-background pl-9 pr-3 text-sm"
>
</label>
<span class="text-xs text-muted-foreground">
{{ visibleRowCount }} / {{ totalRowCount }} 项
</span>
</div>
<p
v-if="searchQuery.trim()"
class="text-xs text-muted-foreground"
>
搜索仅用于定位;移动按完整列表执行,置顶和置底会越过隐藏项。
</p>
</div>
<div class="min-h-[180px] p-3">
<div
v-if="loading"
class="py-10 text-center text-sm text-muted-foreground"
@@ -114,19 +88,18 @@
>
{{ loadError }}
</div>
<div
v-else-if="effectivePriorityMode === 'provider'"
v-else
class="space-y-2"
>
<div
v-if="providerRows.length === 0"
v-if="filteredProviderRows.length === 0"
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
>
暂无 Provider
{{ providerRows.length ? '没有匹配的提供商' : '暂无 Provider' }}
</div>
<div
v-for="(row, index) in providerRows"
v-for="row in filteredProviderRows"
v-else
:key="row.id"
class="group grid min-h-[56px] items-center gap-3 rounded-lg border px-3 py-2 transition-colors"
@@ -150,26 +123,26 @@
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
<GripVertical class="h-4 w-4" />
</div>
<div class="flex items-center gap-1">
<div class="order-last col-span-full flex items-center justify-end gap-1 sm:order-none sm:col-span-1">
<button
v-for="action in providerMoveActions"
:key="action.label"
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="providerMoveDisabled(row.id, index, -1)"
@click="moveProvider(row.id, -1)"
:aria-label="`${action.label} ${row.name}`"
:title="action.label"
:disabled="providerMoveDisabled(row.id, action.direction)"
@click="action.edge ? moveProviderToEdge(row.id, action.edge) : moveProvider(row.id, action.direction)"
>
<ArrowUp class="h-4 w-4" />
</button>
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="providerMoveDisabled(row.id, index, 1)"
@click="moveProvider(row.id, 1)"
>
<ArrowDown class="h-4 w-4" />
<component
:is="action.icon"
class="h-4 w-4"
/>
</button>
</div>
<input
:value="row.priority"
:aria-label="`${row.name} 优先级`"
type="number"
min="0"
class="priority-input h-8 w-14 rounded-md border border-border bg-background px-2 text-center text-sm"
@@ -177,19 +150,36 @@
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{{ row.name }}</span>
<button
type="button"
class="truncate text-left text-sm font-medium hover:text-primary hover:underline"
:aria-label="`查看提供商 ${row.name}`"
@click="emit('inspect-provider', row.id)"
>
{{ row.name }}
</button>
<span
v-if="poolProviderIds.has(row.id)"
class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary"
>
Pool
</span>
>Pool</span>
<span
v-if="!row.is_active"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
停用
>停用</span>
<span
v-if="config.disabled_providers.includes(row.id)"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>本组禁用</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span v-if="row.active_keys != null">可用 Key {{ row.active_keys }} / {{ row.total_keys }}</span>
<span :class="row.health_score != null && row.health_score < 0.8 ? 'text-amber-600 dark:text-amber-400' : ''">
{{ row.health_score == null ? '健康度暂无数据' : `健康度 ${Math.round(row.health_score * 100)}%` }}
</span>
<slot
name="provider-status"
:provider="providerById.get(row.id)"
/>
</div>
</div>
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
@@ -198,91 +188,7 @@
:key="format"
:title="formatLabel(format)"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{{ formatShortLabel(format) }}
</span>
</div>
</div>
</div>
<div
v-else
class="space-y-2"
>
<div
v-if="keyRows.length === 0"
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
>
暂无 Key
</div>
<div
v-for="(row, index) in keyRows"
v-else
:key="row.id"
class="group grid min-h-[56px] items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_56px_minmax(0,1fr)_auto]"
:class="draggedKeyId === row.id
? 'border-primary/50 bg-primary/5 shadow-sm'
: dragOverKeyId === row.id
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:bg-muted/30'"
draggable="true"
@dragstart="handleKeyDragStart(row.id, $event)"
@dragend="handleKeyDragEnd"
@dragover.prevent="handleKeyDragOver(row.id)"
@dragleave="handleKeyDragLeave"
@drop="handleKeyDrop(row.id)"
>
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
<GripVertical class="h-4 w-4" />
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === 0"
@click="moveKey(row.id, -1)"
>
<ArrowUp class="h-4 w-4" />
</button>
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === keyRows.length - 1"
@click="moveKey(row.id, 1)"
>
<ArrowDown class="h-4 w-4" />
</button>
</div>
<input
:value="row.priority"
type="number"
min="0"
class="priority-input h-8 w-14 rounded-md border border-border bg-background px-2 text-center text-sm"
@change="event => setKeyPriority(row.id, event)"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{{ row.name }}</span>
<span
v-if="!row.is_active"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
停用
</span>
</div>
<div class="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{{ row.masked }} · {{ row.provider_name }}
</div>
</div>
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
<span
v-for="format in row.api_formats.slice(0, 3)"
:key="format"
:title="formatLabel(format)"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{{ formatShortLabel(format) }}
</span>
>{{ formatShortLabel(format) }}</span>
</div>
</div>
</div>
@@ -293,38 +199,28 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, ListChecks } from 'lucide-vue-next'
import { ArrowDown, ArrowUp, ChevronsDown, ChevronsUp, GripVertical, ListChecks, Search } from 'lucide-vue-next'
import client from '@/api/client'
import {
Checkbox,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import {
getProvidersSummary,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { formatApiFormat, formatApiFormatShort, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
import { formatApiFormat, formatApiFormatShort } from '@/api/endpoints/types/api-format'
import { parseApiError } from '@/utils/errorParser'
import {
DEFAULT_ROUTING_POLICY_MODEL,
getDefaultModelPolicy,
getModelPolicy,
normalizeRoutingGroupConfig,
normalizeRoutingApiFormatKey,
setModelKeyPriorityOverridesForFormat,
setModelPoolPriorityOverrides,
setModelProviderPriorityOverrides,
type RoutingDefaultPolicy,
type RoutingGroupConfig,
type RoutingPriorityMode,
type RoutingSchedulingMode,
} from '../utils/routingPolicy'
import { buildRoutingProviderSummaryQuery } from '../utils/providerQuery'
import { normalizeProviderSchedulingConfig } from '../utils/schedulingPolicies'
interface ProviderPriorityRow {
id: string
@@ -332,37 +228,9 @@ interface ProviderPriorityRow {
is_active: boolean
api_formats: string[]
priority: number
}
interface KeyPriorityRow {
id: string
kind: 'key' | 'pool'
target_id: string
name: string
masked: string
is_active: boolean
api_formats: string[]
priority: number
provider_id: string
provider_name: string
pool_key_count?: number
pool_active_key_count?: number
}
interface GlobalKeySource {
id: string
provider_id: string
provider_name: string
name: string
api_key_masked: string
internal_priority: number
global_priority_by_format: Record<string, number> | null
is_active: boolean
provider_active: boolean
api_formats: string[]
api_format: string
active_keys: number
total_keys: number
health_score: number | null
request_count: number
}
const props = defineProps<{
@@ -370,17 +238,16 @@ const props = defineProps<{
model?: string
modelId?: string
providerModelIds?: string[]
priorityMode?: RoutingPriorityMode
schedulingMode?: RoutingSchedulingMode
showPriorityMode?: boolean
showSchedulingMode?: boolean
subtitle?: string
refreshRevision?: number
}>()
const emit = defineEmits<{
'update:config': [value: RoutingGroupConfig]
'update:priority-mode': [value: RoutingPriorityMode]
'update:scheduling-mode': [value: RoutingSchedulingMode]
'inspect-provider': [providerId: string]
}>()
const schedulingModes: Array<{ value: RoutingDefaultPolicy['scheduling_mode']; label: string }> = [
@@ -388,36 +255,40 @@ const schedulingModes: Array<{ value: RoutingDefaultPolicy['scheduling_mode']; l
{ value: 'load_balance', label: '负载均衡' },
{ value: 'fixed_order', label: '固定顺序' },
]
const providerMoveActions = [
{ label: '置顶', direction: -1, edge: 'start', icon: ChevronsUp },
{ label: '上移', direction: -1, edge: null, icon: ArrowUp },
{ label: '下移', direction: 1, edge: null, icon: ArrowDown },
{ label: '置底', direction: 1, edge: 'end', icon: ChevronsDown },
] as const
const providers = ref<ProviderWithEndpointsSummary[]>([])
const keysByFormat = ref<Record<string, GlobalKeySource[]>>({})
const selectedApiFormat = ref('')
const searchQuery = ref('')
const loadingProviders = ref(false)
const loadingKeys = ref(false)
const loadError = ref<string | null>(null)
const draggedProviderId = ref<string | null>(null)
const dragOverProviderId = ref<string | null>(null)
const draggedKeyId = ref<string | null>(null)
const dragOverKeyId = ref<string | null>(null)
const providerMultiSelectEnabled = ref(false)
const selectedProviderIds = ref<Set<string>>(new Set())
let providerLoadRequestId = 0
const config = computed(() => normalizeRoutingGroupConfig(props.config))
const config = computed(() => normalizeProviderSchedulingConfig(props.config))
const targetModel = computed(() => props.model?.trim() || DEFAULT_ROUTING_POLICY_MODEL)
const targetModelPolicy = computed(() => targetModel.value === DEFAULT_ROUTING_POLICY_MODEL
? getDefaultModelPolicy(config.value)
: getModelPolicy(config.value, targetModel.value))
const showPriorityMode = computed(() => props.showPriorityMode !== false)
const showSchedulingMode = computed(() => props.showSchedulingMode !== false)
const effectivePriorityMode = computed(() => props.priorityMode ?? config.value.default_policy.priority_mode)
const effectiveSchedulingMode = computed(() => props.schedulingMode ?? config.value.default_policy.scheduling_mode)
const subtitle = computed(() => props.subtitle ?? '默认作用于全部模型')
const loading = computed(() => loadingProviders.value || loadingKeys.value)
const apiFormats = computed(() => sortApiFormats(Object.keys(keysByFormat.value)))
const schedulingDescription = computed(() => {
if (effectiveSchedulingMode.value === 'fixed_order') return '固定顺序:优先级决定候选的尝试次序,实际可用候选还受模型能力和 API 格式影响。'
if (effectiveSchedulingMode.value === 'load_balance') return '负载均衡:请求会分散到可用候选,列表优先级不代表实际尝试顺序。'
return '缓存亲和:会优先复用缓存命中的候选,实际顺序可能与列表不同。'
})
const loading = computed(() => loadingProviders.value)
const providerGridClass = computed(() => providerMultiSelectEnabled.value
? 'sm:grid-cols-[auto_auto_auto_56px_minmax(0,1fr)_auto]'
: 'sm:grid-cols-[auto_auto_56px_minmax(0,1fr)_auto]')
? 'grid-cols-[auto_auto_56px_minmax(0,1fr)] sm:grid-cols-[auto_auto_auto_56px_minmax(0,1fr)_auto]'
: 'grid-cols-[auto_56px_minmax(0,1fr)] sm:grid-cols-[auto_auto_56px_minmax(0,1fr)_auto]')
const providerById = computed(() => {
const map = new Map<string, ProviderWithEndpointsSummary>()
for (const provider of providers.value) {
@@ -425,15 +296,6 @@ const providerById = computed(() => {
}
return map
})
const providerIdByName = computed(() => {
const map = new Map<string, string>()
for (const provider of providers.value) {
if (!map.has(provider.name)) {
map.set(provider.name, provider.id)
}
}
return map
})
const poolProviderIds = computed(() => {
const set = new Set<string>()
for (const provider of providers.value) {
@@ -444,82 +306,45 @@ const poolProviderIds = computed(() => {
return set
})
const providerRows = computed<ProviderPriorityRow[]>(() => {
const overrides = targetModelPolicy.value.provider_priority_overrides
const scopedProviders = computed(() => {
// 多选模型取提供商并集;空数组表示模型尚未解析,不能回退到全部提供商。
const modelIds = props.providerModelIds === undefined ? null : new Set(props.providerModelIds)
return providers.value
.filter(provider => !modelIds || provider.global_model_ids?.some(id => modelIds.has(id)))
})
const providerRows = computed<ProviderPriorityRow[]>(() => {
const overrides = targetModelPolicy.value.provider_priority_overrides
return scopedProviders.value
.map(provider => ({
id: provider.id,
name: provider.name,
is_active: provider.is_active,
api_formats: provider.api_formats ?? [],
priority: priorityValue(overrides[provider.id], provider.provider_priority),
active_keys: provider.active_keys,
total_keys: provider.total_keys,
health_score: provider.avg_health_score ?? null,
}))
.sort(comparePriorityRows)
})
const selectedFormatKey = computed(() => normalizeRoutingApiFormatKey(selectedApiFormat.value))
const selectedFormatKeyOverrides = computed<Record<string, number>>(() => (
targetModelPolicy.value.key_priority_overrides_by_format[selectedFormatKey.value] ?? {}
))
// Search only affects presentation. All move operations use the complete candidate list.
const filteredProviderRows = computed(() => providerRows.value.filter(row => matchesSearch(row.name)))
const totalRowCount = computed(() => providerRows.value.length)
const visibleRowCount = computed(() => filteredProviderRows.value.length)
const keyRows = computed<KeyPriorityRow[]>(() => {
const format = selectedApiFormat.value
// 按格式覆盖优先;旧的不分格式覆盖仅作为兜底展示
const keyOverrides: Record<string, number> = {
...targetModelPolicy.value.key_priority_overrides,
...selectedFormatKeyOverrides.value,
}
const poolOverrides = targetModelPolicy.value.pool_priority_overrides
const normalRows: KeyPriorityRow[] = []
const poolGroups = new Map<string, GlobalKeySource[]>()
for (const key of keysByFormat.value[format] ?? []) {
const providerId = resolveProviderId(key)
if (isPoolManagedProvider(providerId)) {
if (!poolGroups.has(providerId)) {
poolGroups.set(providerId, [])
}
poolGroups.get(providerId)?.push(key)
continue
}
normalRows.push({
id: key.id,
kind: 'key',
target_id: key.id,
name: key.name,
masked: key.api_key_masked,
is_active: key.is_active && key.provider_active,
api_formats: key.api_formats,
priority: priorityValue(keyOverrides[key.id], fallbackKeyPriority(key, format)),
provider_id: providerId,
provider_name: key.provider_name,
})
}
const poolRows = Array.from(poolGroups.entries()).map(([providerId, keys]) =>
buildPoolRow(format, providerId, keys, poolOverrides)
)
return [...normalRows, ...poolRows].sort(comparePriorityRows)
})
watch(effectivePriorityMode, mode => {
if (mode === 'global_key') {
void loadGlobalKeys()
providerMultiSelectEnabled.value = false
selectedProviderIds.value = new Set()
}
void loadProviders()
})
function matchesSearch(value: string): boolean {
return value.toLocaleLowerCase().includes(searchQuery.value.trim().toLocaleLowerCase())
}
// 父组件异步解析全局模型 ID 后,重新加载对应模型的提供商列表。
watch([targetModel, () => props.modelId], () => {
void loadProviders()
})
watch(() => props.refreshRevision, () => { void loadProviders() })
watch(providerRows, rows => {
const visibleIds = new Set(rows.map(row => row.id))
const next = new Set([...selectedProviderIds.value].filter(id => visibleIds.has(id)))
@@ -528,23 +353,10 @@ watch(providerRows, rows => {
}
})
watch(apiFormats, formats => {
if (!formats.includes(selectedApiFormat.value)) {
selectedApiFormat.value = formats[0] ?? ''
}
})
onMounted(() => {
void (async () => {
await loadProviders()
if (effectivePriorityMode.value === 'global_key') {
await loadGlobalKeys()
}
})()
})
onMounted(() => { void loadProviders() })
function updateConfig(value: RoutingGroupConfig): void {
emit('update:config', normalizeRoutingGroupConfig(value))
emit('update:config', normalizeProviderSchedulingConfig(value))
}
function updateDefaultPolicy(patch: Partial<RoutingDefaultPolicy>): void {
@@ -557,14 +369,6 @@ function updateDefaultPolicy(patch: Partial<RoutingDefaultPolicy>): void {
})
}
function updatePriorityMode(mode: RoutingPriorityMode): void {
if (props.priorityMode != null) {
emit('update:priority-mode', mode)
return
}
updateDefaultPolicy({ priority_mode: mode })
}
function updateSchedulingMode(mode: RoutingSchedulingMode): void {
if (props.schedulingMode != null) {
emit('update:scheduling-mode', mode)
@@ -581,7 +385,7 @@ async function loadProviders(): Promise<void> {
const query = buildRoutingProviderSummaryQuery(
targetModel.value,
props.modelId,
effectivePriorityMode.value,
'provider',
)
if (!query) {
providers.value = []
@@ -602,31 +406,6 @@ async function loadProviders(): Promise<void> {
}
}
async function loadGlobalKeys(force = false): Promise<void> {
if (!force && Object.keys(keysByFormat.value).length > 0) return
loadingKeys.value = true
loadError.value = null
try {
const response = await client.get<Record<string, Record<string, unknown>[]>>(
'/api/admin/endpoints/keys/grouped-by-format',
)
const next: Record<string, GlobalKeySource[]> = {}
for (const [rawFormat, rawKeys] of Object.entries(response.data ?? {})) {
const format = normalizeFormat(rawFormat)
if (!format) continue
next[format] = normalizeGlobalKeys(format, rawKeys)
}
keysByFormat.value = next
if (!selectedApiFormat.value || !Object.keys(next).includes(selectedApiFormat.value)) {
selectedApiFormat.value = sortApiFormats(Object.keys(next))[0] ?? ''
}
} catch (err) {
loadError.value = parseApiError(err, '加载全局 Key 失败')
} finally {
loadingKeys.value = false
}
}
function setProviderPriority(providerId: string, event: Event): void {
const priority = readPriorityInput(event)
if (priority == null) return
@@ -641,7 +420,19 @@ function moveProvider(providerId: string, direction: -1 | 1): void {
const rows = movingIds.length > 1
? moveRowsByGroup(providerRows.value, movingIds, direction)
: moveRow(providerRows.value, providerId, direction)
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
updateReorderedProviders(rows, movingIds)
}
function moveProviderToEdge(providerId: string, edge: 'start' | 'end'): void {
const movingIds = providerMoveIds(providerId)
updateReorderedProviders(moveRowsToEdge(providerRows.value, movingIds, edge), movingIds)
}
function updateReorderedProviders(rows: ProviderPriorityRow[], movingIds: string[]): void {
updateProviderOverrides({
...targetModelPolicy.value.provider_priority_overrides,
...reorderedPriorities(rows, movingIds),
})
}
function updateProviderOverrides(overrides: Record<string, number>): void {
@@ -679,9 +470,10 @@ function providerMoveIds(providerId: string): string[] {
.filter(id => selectedProviderIds.value.has(id))
}
function providerMoveDisabled(providerId: string, index: number, direction: -1 | 1): boolean {
function providerMoveDisabled(providerId: string, direction: -1 | 1): boolean {
const movingIds = providerMoveIds(providerId)
if (movingIds.length <= 1) {
const index = providerRows.value.findIndex(row => row.id === providerId)
return direction === -1 ? index === 0 : index === providerRows.value.length - 1
}
const movingSet = new Set(movingIds)
@@ -712,83 +504,6 @@ function isProviderDragged(providerId: string): boolean {
return Boolean(draggedId && providerMoveIds(draggedId).includes(providerId))
}
function setKeyPriority(keyId: string, event: Event): void {
const priority = readPriorityInput(event)
if (priority == null) return
const row = keyRows.value.find(item => item.id === keyId)
if (!row) return
if (row.kind === 'pool') {
updatePoolOverrides({
...targetModelPolicy.value.pool_priority_overrides,
[row.target_id]: priority,
})
} else {
updateKeyOverrides({
...selectedFormatKeyOverrides.value,
[row.target_id]: priority,
})
}
}
function moveKey(keyId: string, direction: -1 | 1): void {
const rows = moveRow(keyRows.value, keyId, direction)
updateVisibleKeyAndPoolOverrides(rows)
}
// Key 覆盖始终写入当前选中的 API 格式,不同格式互不影响
function updateKeyOverrides(overrides: Record<string, number>): void {
updateConfig(setModelKeyPriorityOverridesForFormat(
config.value,
targetModel.value,
selectedApiFormat.value,
overrides,
))
}
function updatePoolOverrides(overrides: Record<string, number>): void {
updateConfig(setModelPoolPriorityOverrides(config.value, targetModel.value, overrides))
}
function updateKeyAndPoolOverrides(
keyOverrides: Record<string, number>,
poolOverrides: Record<string, number>,
): void {
const next = setModelPoolPriorityOverrides(
setModelKeyPriorityOverridesForFormat(
config.value,
targetModel.value,
selectedApiFormat.value,
keyOverrides,
),
targetModel.value,
poolOverrides,
)
updateConfig(next)
}
function updateVisibleKeyAndPoolOverrides(rows: KeyPriorityRow[]): void {
const keyOverrides = { ...selectedFormatKeyOverrides.value }
const poolOverrides = { ...targetModelPolicy.value.pool_priority_overrides }
for (const row of keyRows.value) {
if (row.kind === 'pool') {
delete poolOverrides[row.target_id]
} else {
delete keyOverrides[row.target_id]
}
}
rows.forEach((row, index) => {
if (row.kind === 'pool') {
poolOverrides[row.target_id] = index
} else {
keyOverrides[row.target_id] = index
}
})
updateKeyAndPoolOverrides(keyOverrides, poolOverrides)
}
function handleProviderDragStart(providerId: string, event: DragEvent): void {
draggedProviderId.value = providerId
if (event.dataTransfer) {
@@ -824,42 +539,10 @@ function handleProviderDrop(providerId: string): void {
const rows = movingIds.length > 1
? reorderRowsByGroup(providerRows.value, movingIds, providerId)
: reorderRows(providerRows.value, draggedId, providerId)
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
updateReorderedProviders(rows, movingIds)
handleProviderDragEnd()
}
function handleKeyDragStart(keyId: string, event: DragEvent): void {
draggedKeyId.value = keyId
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', keyId)
}
}
function handleKeyDragEnd(): void {
draggedKeyId.value = null
dragOverKeyId.value = null
}
function handleKeyDragOver(keyId: string): void {
dragOverKeyId.value = keyId
}
function handleKeyDragLeave(): void {
dragOverKeyId.value = null
}
function handleKeyDrop(keyId: string): void {
const draggedId = draggedKeyId.value
if (!draggedId || draggedId === keyId) {
handleKeyDragEnd()
return
}
const rows = reorderRows(keyRows.value, draggedId, keyId)
updateVisibleKeyAndPoolOverrides(rows)
handleKeyDragEnd()
}
function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1 | 1): T[] {
const next = [...rows]
const index = next.findIndex(row => row.id === id)
@@ -872,6 +555,26 @@ function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1
return next
}
function moveRowsToEdge<T extends { id: string }>(rows: T[], movingIds: string[], edge: 'start' | 'end'): T[] {
const movingSet = new Set(movingIds)
const movingRows = rows.filter(row => movingSet.has(row.id))
const remainingRows = rows.filter(row => !movingSet.has(row.id))
return edge === 'start' ? [...movingRows, ...remainingRows] : [...remainingRows, ...movingRows]
}
function reorderedPriorities<T extends { id: string, priority: number }>(rows: T[], movingIds: string[]): Record<string, number> {
const movingSet = new Set(movingIds)
let priority = 0
return Object.fromEntries(rows.map((row, index) => {
const previous = rows[index - 1]
// Keep untouched, contiguous priority groups together. The moved rows form their own position.
if (previous && (previous.priority !== row.priority || movingSet.has(previous.id) !== movingSet.has(row.id))) {
priority += 1
}
return [row.id, priority]
}))
}
function moveRowsByGroup<T extends { id: string }>(rows: T[], movingIds: string[], direction: -1 | 1): T[] {
const movingSet = new Set(movingIds)
const movingRows = rows.filter(row => movingSet.has(row.id))
@@ -939,95 +642,6 @@ function priorityValue(override: number | undefined, fallback: number | null | u
return 0
}
function fallbackKeyPriority(key: GlobalKeySource, format: string): number {
const normalizedFormat = normalizeFormat(format)
if (normalizedFormat && typeof key.global_priority_by_format?.[normalizedFormat] === 'number') {
return key.global_priority_by_format[normalizedFormat]
}
return key.internal_priority
}
function normalizeGlobalKeys(format: string, rawKeys: Record<string, unknown>[]): GlobalKeySource[] {
const deduped = new Map<string, GlobalKeySource>()
for (const raw of rawKeys) {
const id = String(raw.id || '').trim()
if (!id) continue
const providerName = String(raw.provider_name || '')
const providerId = String(raw.provider_id || '') || providerIdByName.value.get(providerName) || ''
const priorityMap = normalizePriorityMap(raw.global_priority_by_format as Record<string, unknown> | null | undefined)
const source: GlobalKeySource = {
id,
provider_id: providerId,
provider_name: providerName || providerById.value.get(providerId)?.name || 'Unknown Provider',
name: String(raw.name || 'Unnamed Key'),
api_key_masked: String(raw.api_key_masked || '***'),
internal_priority: toNumberOrNull(raw.internal_priority) ?? 0,
global_priority_by_format: Object.keys(priorityMap).length > 0 ? priorityMap : null,
is_active: raw.is_active !== false,
provider_active: raw.provider_active !== false,
api_formats: Array.isArray(raw.api_formats) ? raw.api_formats.map(item => normalizeFormat(String(item))).filter(Boolean) : [format],
api_format: format,
health_score: toNumberOrNull(raw.health_score),
request_count: toNumberOrNull(raw.request_count) ?? 0,
}
const existing = deduped.get(id)
if (!existing) {
deduped.set(id, source)
continue
}
deduped.set(id, {
...existing,
...source,
global_priority_by_format: {
...(existing.global_priority_by_format ?? {}),
...(source.global_priority_by_format ?? {}),
},
api_formats: Array.from(new Set([...existing.api_formats, ...source.api_formats])),
})
}
return Array.from(deduped.values())
}
function buildPoolRow(
format: string,
providerId: string,
keys: GlobalKeySource[],
overrides: Record<string, number>,
): KeyPriorityRow {
const provider = providerById.value.get(providerId)
const activeKeyCount = keys.filter(key => key.is_active).length
return {
id: `pool:${providerId}:${format}`,
kind: 'pool',
target_id: providerId,
name: provider?.name || keys[0]?.provider_name || '未知 Provider',
masked: '[Pool]',
is_active: (provider?.is_active ?? keys.some(key => key.provider_active)) && activeKeyCount > 0,
api_formats: [format],
priority: priorityValue(
overrides[providerId],
provider?.pool_advanced?.global_priority ?? provider?.provider_priority ?? 999999,
),
provider_id: providerId,
provider_name: provider?.name || keys[0]?.provider_name || 'Unknown Provider',
pool_key_count: keys.length,
pool_active_key_count: activeKeyCount,
}
}
function resolveProviderId(key: Pick<GlobalKeySource, 'provider_id' | 'provider_name'>): string {
if (key.provider_id) return key.provider_id
return providerIdByName.value.get(key.provider_name) || ''
}
function isPoolManagedProvider(providerId: string): boolean {
return providerId !== '' && poolProviderIds.value.has(providerId)
}
function normalizeFormat(value: string | null | undefined): string {
return normalizeApiFormatAlias(value).trim()
}
function formatLabel(format: string): string {
return formatApiFormat(format)
}
@@ -1036,24 +650,7 @@ function formatShortLabel(format: string): string {
return formatApiFormatShort(format)
}
function normalizePriorityMap(value: Record<string, unknown> | null | undefined): Record<string, number> {
if (!value) return {}
const normalized: Record<string, number> = {}
for (const [rawFormat, rawPriority] of Object.entries(value)) {
const format = normalizeFormat(rawFormat)
const priority = toNumberOrNull(rawPriority)
if (!format || priority == null) continue
normalized[format] = priority
}
return normalized
}
function toNumberOrNull(value: unknown): number | null {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : null
}
function comparePriorityRows(left: ProviderPriorityRow | KeyPriorityRow, right: ProviderPriorityRow | KeyPriorityRow): number {
function comparePriorityRows(left: ProviderPriorityRow, right: ProviderPriorityRow): number {
return left.priority - right.priority
|| Number(right.is_active) - Number(left.is_active)
|| left.name.localeCompare(right.name)
@@ -1,6 +1,14 @@
<template>
<section class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<section
:class="[
layout === 'config-only' ? 'config-only-editor space-y-2' : 'space-y-4',
{ 'config-only-editor--sidebar': layout === 'config-only' && sidebar },
]"
>
<div
v-if="layout !== 'config-only'"
class="flex flex-wrap items-start justify-between gap-3"
>
<div>
<h3 class="text-sm font-medium">
调度配置
@@ -23,9 +31,52 @@
</div>
<div
v-if="layout === 'config-only'"
class="scheduling-toolbar"
:class="{
'scheduling-toolbar--composed': !sidebar && ($slots['toolbar-leading'] || $slots['toolbar-actions']),
'scheduling-toolbar--sidebar': sidebar,
}"
>
<div
v-if="$slots['toolbar-leading']"
class="scheduling-toolbar__leading min-w-0"
>
<slot name="toolbar-leading" />
</div>
<div class="scheduling-toolbar__scope min-w-0 space-y-1.5">
<span class="flex h-6 items-center text-xs font-medium text-muted-foreground">模型配置</span>
<div
role="group"
aria-label="调度范围"
class="scheduling-switch w-full grid-cols-2"
>
<button
v-for="mode in scopeModes"
:key="mode.value"
type="button"
class="scheduling-switch__option"
:aria-label="mode.label"
:aria-pressed="scopeMode === mode.value"
:disabled="disabled"
@click="setScopeMode(mode.value)"
>
{{ mode.value === 'all' ? '全局配置' : mode.label }}
</button>
</div>
</div>
<div
v-if="!sidebar && $slots['toolbar-actions']"
class="scheduling-toolbar__actions min-w-0"
>
<slot name="toolbar-actions" />
</div>
</div>
<div
v-else
role="group"
aria-label="调度范围"
class="scheduling-switch w-full grid-cols-2 sm:w-80"
class="scheduling-switch w-full grid-cols-2"
>
<button
v-for="mode in scopeModes"
@@ -37,15 +88,167 @@
:disabled="disabled"
@click="setScopeMode(mode.value)"
>
<component
:is="mode.icon"
class="h-4 w-4 shrink-0"
/>
{{ mode.label }}
</button>
</div>
<div
v-if="layout === 'config-only' && scopeMode === 'all' && selectedEntry"
class="min-w-0 space-y-1.5 border-t border-border/50 pt-2"
>
<div class="flex h-6 items-center gap-1 text-xs font-medium text-muted-foreground">
<span>调度策略</span>
<HelpHint
label="调度策略"
:text="schedulingDescription"
:portal="sidebar"
/>
</div>
<div
role="group"
aria-label="调度策略"
class="scheduling-switch w-full grid-cols-3"
>
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="scheduling-switch__option"
:aria-pressed="selectedEntry.schedulingMode === mode.value"
:disabled="disabled"
@click="updateEntry(selectedEntry.id, { schedulingMode: mode.value })"
>
{{ mode.label }}
</button>
</div>
</div>
<fieldset
v-if="layout === 'config-only' && scopeMode === 'selected'"
:disabled="disabled"
:inert="disabled"
class="min-w-0 space-y-2 border-t border-border/50 pt-2"
>
<div
role="group"
aria-label="模型调度配置"
class="space-y-1.5"
>
<section
v-for="(entry, index) in entries"
:key="entry.id"
class="min-w-0 overflow-hidden rounded-md border transition-colors"
:class="selectedEntryId === entry.id ? 'border-primary/40 bg-primary/5' : 'border-border/60 bg-background'"
:aria-label="`调度配置 ${index + 1}`"
>
<div class="flex min-w-0 items-center">
<button
type="button"
class="flex min-h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:opacity-50"
:aria-label="`选择调度配置 ${index + 1}`"
:aria-pressed="selectedEntryId === entry.id"
:aria-expanded="expandedId === entry.id"
:disabled="disabled"
@click="toggleEntry(entry.id)"
>
<ChevronRight
class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform"
:class="expandedId === entry.id ? 'rotate-90' : ''"
/>
<span
class="min-w-0 flex-1 truncate font-medium"
:title="entry.scope === 'selected' ? entry.models.join('、') : '默认配置'"
>{{ scopeSummary(entry) }}</span>
<span class="shrink-0 text-muted-foreground">{{ schedulingModeLabel(entry.schedulingMode) }}</span>
</button>
<Button
v-if="entries.length > 1"
type="button"
variant="ghost"
size="icon"
class="mr-0.5 h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
:aria-label="`删除调度配置 ${index + 1}`"
:disabled="disabled"
@click="removeEntry(entry.id)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<div
v-if="selectedEntryId === entry.id && expandedId === entry.id"
role="region"
aria-label="当前配置的适用模型"
class="min-w-0 border-t border-border/50"
>
<RoutingModelSelector
v-if="entry.scope === 'selected'"
compact
inline
:narrow="sidebar"
:model-value="entry.models"
:models="globalModels"
:assigned-models="otherModelOwners(entry.id)"
:loading="loadingModels"
:error="modelsError"
:disabled="disabled"
@update:model-value="models => updateEntry(entry.id, { models })"
@reload="emit('reload-models')"
/>
<p
v-else
class="px-2 py-2 text-xs leading-5 text-muted-foreground"
>
此默认配置适用于未单独指定的模型,新增模型也会自动使用。
</p>
</div>
<div
v-if="selectedEntryId === entry.id && expandedId === entry.id"
class="min-w-0 space-y-1.5 border-t border-border/50 p-2"
>
<div class="flex h-6 items-center gap-1 text-xs font-medium text-muted-foreground">
<span>调度策略</span>
<HelpHint
label="调度策略"
:text="schedulingDescription"
:portal="sidebar"
/>
</div>
<div
role="group"
aria-label="调度策略"
class="scheduling-switch w-full grid-cols-3"
>
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="scheduling-switch__option"
:aria-pressed="entry.schedulingMode === mode.value"
:disabled="disabled"
@click="updateEntry(entry.id, { schedulingMode: mode.value })"
>
{{ mode.label }}
</button>
</div>
</div>
</section>
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-full gap-1 px-2 text-xs"
:disabled="!canAddEntry"
:title="addEntryHint"
aria-label="添加调度配置"
@click="addEntry"
>
<Plus class="h-3.5 w-3.5" />
添加配置
</Button>
</fieldset>
<fieldset
v-if="layout !== 'config-only'"
:disabled="disabled"
:inert="disabled"
class="min-w-0 space-y-3"
@@ -65,11 +268,11 @@
class="flex min-w-0 flex-1 items-center gap-3 text-left"
:aria-label="`${expandedId === entry.id ? '收起' : '展开'}调度配置 ${index + 1}`"
:aria-expanded="expandedId === entry.id"
@click="expandedId = expandedId === entry.id ? null : entry.id"
@click="toggleEntry(entry.id)"
>
<ChevronDown
<ChevronRight
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
:class="expandedId === entry.id ? 'rotate-180' : ''"
:class="expandedId === entry.id ? 'rotate-90' : ''"
/>
<span class="min-w-0">
<span
@@ -79,7 +282,7 @@
{{ scopeSummary(entry) }}
</span>
<span class="mt-0.5 block text-xs text-muted-foreground">
配置 {{ index + 1 }} · {{ entry.priorityMode === 'provider' ? 'Provider' : 'Key' }} · {{ schedulingModeLabel(entry.schedulingMode) }}
配置 {{ index + 1 }} · {{ schedulingModeLabel(entry.schedulingMode) }}
</span>
</span>
</button>
@@ -130,31 +333,17 @@
<h4 class="text-sm font-medium">
调度设置
</h4>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="space-y-1.5 text-sm">
<span class="text-muted-foreground">调度优先级</span>
<div
role="group"
aria-label="调度优先级"
class="scheduling-switch grid-cols-2"
>
<button
v-for="mode in priorityModes"
:key="mode.value"
type="button"
class="scheduling-switch__option"
:aria-pressed="entry.priorityMode === mode.value"
:disabled="disabled"
@click="updateEntry(entry.id, { priorityMode: mode.value })"
>
<component
:is="mode.icon"
class="h-4 w-4"
/>
{{ mode.label }}
</button>
</div>
</div>
<div class="rounded-md bg-primary/5 px-3 py-2 text-xs leading-relaxed text-muted-foreground">
<template v-if="entry.scope === 'all'">
{{ scopeMode === 'all' ? '此配置适用于全部模型,新增模型也会自动使用。' : '此默认配置适用于未单独指定的模型。' }}
</template>
<template v-else>
正在编辑 {{ entry.models.length }} 个模型共用的配置:
<span class="text-foreground">{{ entry.models.join('、') }}</span>。
此处修改调度方式或排序,会同时应用于这些模型。
</template>
</div>
<div class="max-w-xl">
<div class="space-y-1.5 text-sm">
<span class="text-muted-foreground">调度策略</span>
<div
@@ -179,11 +368,19 @@
<RoutingPriorityPolicyEditor
:config="schedulingPolicyEditorConfig(config, entry)"
:provider-model-ids="entry.scope === 'selected' ? providerModelIds(entry) : undefined"
:show-priority-mode="false"
:show-scheduling-mode="false"
subtitle="所选模型共用此排序,仅对各模型可用的候选生效"
:refresh-revision="refreshRevision"
:subtitle="entry.scope === 'all' ? '此配置范围内的模型共用排序,仅对实际可用的候选生效' : `${entry.models.length} 个模型共用此排序,仅对各模型可用的候选生效`"
@update:config="value => updateEntry(entry.id, { policy: getDefaultModelPolicy(value) })"
/>
@inspect-provider="providerId => emit('inspect-provider', providerId)"
>
<template #provider-status="{ provider }">
<slot
name="provider-status"
:provider="provider"
/>
</template>
</RoutingPriorityPolicyEditor>
</div>
<p
v-else
@@ -202,22 +399,29 @@
{{ validationError }}
</p>
<p
v-if="scopeMode === 'selected' && !hasAllModels"
v-if="layout !== 'config-only' && scopeMode === 'selected' && !hasAllModels"
class="text-xs text-muted-foreground"
>
{{ availableModels.length ? `还有 ${availableModels.length} 个模型可配置;` : '' }}未指定的模型继续使用默认调度。
</p>
<div
v-if="layout === 'config-only' && sidebar && $slots['toolbar-actions']"
class="min-w-0 border-t border-border/50 pt-3"
>
<slot name="toolbar-actions" />
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ChevronDown, Globe, Key, Layers, ListFilter, Plus, Trash2 } from 'lucide-vue-next'
import { ChevronRight, Plus, Trash2 } from 'lucide-vue-next'
import { Button } from '@/components/ui'
import HelpHint from '@/components/common/HelpHint.vue'
import type { GlobalModelResponse } from '@/api/global-models'
import RoutingPriorityPolicyEditor from './RoutingPriorityPolicyEditor.vue'
import RoutingModelSelector from './RoutingModelSelector.vue'
import { getDefaultModelPolicy, type RoutingGroupConfig, type RoutingPriorityMode, type RoutingSchedulingMode } from '../utils/routingPolicy'
import { getDefaultModelPolicy, normalizeRoutingGroupConfig, type RoutingGroupConfig, type RoutingModelPolicy, type RoutingPriorityMode, type RoutingSchedulingMode } from '../utils/routingPolicy'
import {
createSchedulingPolicy,
readSchedulingPolicies,
@@ -229,25 +433,38 @@ import {
const props = defineProps<{
config: RoutingGroupConfig
refreshRevision?: number
globalModels: GlobalModelResponse[]
loadingModels?: boolean
modelsError?: string | null
disabled?: boolean
layout?: 'embedded' | 'config-only'
sidebar?: boolean
initialSelection?: {
id?: string | null
scope: SchedulingPolicy['scope']
modelNames: string[]
}
}>()
const emit = defineEmits<{
'update:config': [value: RoutingGroupConfig]
'validity-change': [valid: boolean]
'reload-models': []
'inspect-provider': [providerId: string]
'selection-change': [value: {
id: string | null
policy: RoutingModelPolicy | null
priorityMode: RoutingPriorityMode
schedulingMode: RoutingSchedulingMode
scope: SchedulingPolicy['scope']
modelNames: string[]
}]
}>()
const scopeModes = [
{ value: 'all' as const, label: '全部模型', icon: Globe },
{ value: 'selected' as const, label: '区分模型', icon: ListFilter },
]
const priorityModes = [
{ value: 'provider' as RoutingPriorityMode, label: 'Provider', icon: Layers },
{ value: 'global_key' as RoutingPriorityMode, label: 'Key', icon: Key },
{ value: 'all' as const, label: '全部模型' },
{ value: 'selected' as const, label: '区分模型' },
]
const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> = [
{ value: 'cache_affinity', label: '缓存亲和' },
@@ -255,14 +472,24 @@ const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> =
{ value: 'fixed_order', label: '固定顺序' },
]
const entries = ref(readSchedulingPolicies(props.config))
const initialSelection = props.initialSelection
const initialSelectedEntry = entries.value.find(entry => entry.id === initialSelection?.id)
?? entries.value.find(entry => initialSelection
&& entry.scope === initialSelection.scope
&& entry.models.length === initialSelection.modelNames.length
&& entry.models.every(model => initialSelection.modelNames.includes(model)))
?? entries.value[0]
const config = computed(() => normalizeRoutingGroupConfig(props.config))
const layout = computed(() => props.layout ?? 'embedded')
const selectedEntryId = ref<string | null>(initialSelectedEntry?.id ?? null)
const scopeMode = ref<SchedulingPolicy['scope']>(entries.value.some(entry => entry.scope === 'selected') ? 'selected' : 'all')
let allModelsDraft: SchedulingPolicy[] | null = null
let selectedModelsDraft: SchedulingPolicy[] | null = null
const fallbackScheduling = {
priority_mode: props.config.default_policy.priority_mode,
priority_mode: 'provider' as const,
scheduling_mode: props.config.default_policy.scheduling_mode,
}
const expandedId = ref<string | null>(entries.value[0]?.id ?? null)
const expandedId = ref<string | null>(initialSelectedEntry?.id ?? null)
const validationError = computed(() => validateSchedulingPolicies(entries.value))
const hasAllModels = computed(() => entries.value.some(entry => entry.scope === 'all'))
const assignedModels = computed(() => new Set(entries.value.filter(entry => entry.scope === 'selected').flatMap(entry => entry.models)))
@@ -276,7 +503,66 @@ const addEntryHint = computed(() => {
return availableModels.value.length ? '为其他模型添加一套调度配置' : '所有全局模型都已有配置'
})
const selectedEntry = computed(() => entries.value.find(entry => entry.id === selectedEntryId.value) ?? entries.value[0] ?? null)
const schedulingDescription = computed(() => {
if (selectedEntry.value?.schedulingMode === 'fixed_order') return '固定顺序:优先级决定候选的尝试次序,实际可用候选还受模型能力和 API 格式影响。'
if (selectedEntry.value?.schedulingMode === 'load_balance') return '负载均衡:请求会分散到可用候选,列表优先级不代表实际尝试顺序。'
return '缓存亲和:会优先复用缓存命中的候选,实际顺序可能与列表不同。'
})
function emitSelection(): void {
const entry = selectedEntry.value
emit('selection-change', entry
? {
id: entry.id,
policy: entry.scope === 'selected' && !entry.models.length
? null
: getDefaultModelPolicy({ ...config.value, model_policies: [entry.policy] }),
priorityMode: 'provider',
schedulingMode: entry.schedulingMode,
scope: entry.scope,
modelNames: [...entry.models],
}
: {
id: null,
policy: null,
priorityMode: 'provider',
schedulingMode: config.value.default_policy.scheduling_mode,
scope: scopeMode.value,
modelNames: [],
})
}
function toggleEntry(id: string): void {
if (props.disabled) return
if (layout.value === 'config-only') {
if (selectedEntryId.value === id) expandedId.value = expandedId.value === id ? null : id
else selectEntry(id)
return
}
if (expandedId.value === id) expandedId.value = null
else {
expandedId.value = id
}
}
function selectEntry(id: string): void {
if (props.disabled) return
selectedEntryId.value = id
expandedId.value = id
emitSelection()
}
function updateSelectedPolicy(policy: RoutingModelPolicy): void {
const entry = selectedEntry.value
if (!entry || props.disabled || entry.scope === 'selected' && !entry.models.length) return
updateEntry(entry.id, { policy: { ...policy, model: '*' } })
}
defineExpose({ updateSelectedPolicy, selectEntry })
watch(validationError, error => emit('validity-change', !error), { immediate: true })
watch([selectedEntry, entries, scopeMode], emitSelection, { immediate: true, deep: true })
function schedulingModeLabel(mode: RoutingSchedulingMode): string {
return schedulingModes.find(item => item.value === mode)?.label ?? mode
@@ -320,20 +606,23 @@ function setScopeMode(scope: SchedulingPolicy['scope']): void {
?? createSchedulingPolicy(props.config, scope)
entries.value = [{
...createSchedulingPolicy(props.config, scope),
priorityMode: source.priorityMode,
priorityMode: 'provider',
schedulingMode: source.schedulingMode,
policy: source.policy,
}]
}
scopeMode.value = scope
expandedId.value = entries.value[0]?.id ?? null
selectedEntryId.value = entries.value[0]?.id ?? null
publish()
}
function updateEntry(id: string, patch: Partial<SchedulingPolicy>): void {
if (props.disabled) return
const current = entries.value.find(entry => entry.id === id)
if (!current || Object.entries(patch).every(([field, value]) => current[field as keyof SchedulingPolicy] === value)) return
if (!current) return
if (layout.value === 'config-only') selectedEntryId.value = id
if (Object.entries(patch).every(([field, value]) => current[field as keyof SchedulingPolicy] === value)) return
entries.value = entries.value.map(entry => {
if (entry.id !== id) return entry
const updated = { ...entry, ...patch }
@@ -349,6 +638,7 @@ function addEntry(): void {
if (!canAddEntry.value) return
const entry = createSchedulingPolicy(props.config)
entries.value.push(entry)
selectedEntryId.value = entry.id
expandedId.value = entry.id
publish()
}
@@ -361,11 +651,57 @@ function removeEntry(id: string): void {
selectedModelsDraft = null
}
if (expandedId.value === id) expandedId.value = entries.value[0]?.id ?? null
if (selectedEntryId.value === id) selectedEntryId.value = entries.value[0]?.id ?? null
publish()
}
</script>
<style scoped>
.scheduling-toolbar {
display: grid;
min-width: 0;
align-items: end;
gap: 12px;
}
@media (min-width: 640px) {
.scheduling-toolbar:not(.scheduling-toolbar--sidebar) {
grid-template-columns: minmax(0, 300px) minmax(0, 220px);
column-gap: 20px;
}
.scheduling-toolbar--composed {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.scheduling-toolbar--composed .scheduling-toolbar__leading {
grid-area: 1 / 1;
}
.scheduling-toolbar--composed .scheduling-toolbar__actions {
grid-area: 1 / 2;
justify-self: end;
}
.scheduling-toolbar--composed .scheduling-toolbar__scope {
grid-area: 2 / 1 / 3 / -1;
}
}
@media (min-width: 1280px) {
.scheduling-toolbar--composed {
grid-template-columns: minmax(200px, 280px) minmax(190px, 220px) minmax(max-content, 1fr);
}
.scheduling-toolbar--composed .scheduling-toolbar__scope {
grid-area: 1 / 2;
}
.scheduling-toolbar--composed .scheduling-toolbar__actions {
grid-area: 1 / 3;
}
}
.scheduling-switch {
display: grid;
gap: 4px;
@@ -395,6 +731,22 @@ function removeEntry(id: string): void {
transition: background-color 150ms, border-color 150ms, color 150ms, box-shadow 150ms;
}
.config-only-editor .scheduling-switch {
gap: 2px;
padding: 2px;
}
.config-only-editor .scheduling-switch__option {
min-height: 30px;
padding: 4px;
font-size: 13px;
}
.config-only-editor--sidebar .scheduling-switch__option {
gap: 4px;
font-size: 12px;
}
.scheduling-switch__option:hover:not(:disabled) {
background: color-mix(in oklab, var(--primary) 8%, var(--background));
color: var(--foreground);
@@ -65,6 +65,7 @@ export interface RoutingSetSchedulingAction {
}
export interface RoutingGroupConfig {
disabled_providers: string[]
default_policy: RoutingDefaultPolicy
model_policies: RoutingModelPolicy[]
rules: RoutingRule[]
@@ -86,6 +87,7 @@ export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
cancel_on_client_disconnect: false,
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
},
disabled_providers: [],
model_policies: [],
rules: [],
}
@@ -134,6 +136,9 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
rawDefaultPolicy.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS,
),
},
disabled_providers: Array.isArray(value?.disabled_providers)
? [...new Set(value.disabled_providers.filter((id): id is string => typeof id === 'string' && id.length > 0))]
: [],
model_policies: Array.isArray(value?.model_policies)
? value.model_policies.map(policy => ({
...createEmptyModelPolicy(policy.model),
@@ -28,13 +28,29 @@ export interface SchedulingPolicy {
rule?: RoutingRule
}
/** Retain legacy ranking data while making provider order the only scheduling dimension. */
export function normalizeProviderSchedulingConfig(config: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
next.default_policy.priority_mode = 'provider'
next.rules = next.rules.map(rule => ({
...rule,
actions: rule.actions.map(action => {
if (!action || typeof action !== 'object') return action
const value = action as Record<string, unknown>
if (value.type !== 'set_scheduling' || value.priority_mode == null || value.priority_mode === 'provider') return action
return { ...value, priority_mode: 'provider' }
}),
}))
return next
}
export function createSchedulingPolicy(config: RoutingGroupConfig, scope: SchedulingPolicy['scope'] = 'selected'): SchedulingPolicy {
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
return {
id: `${SCHEDULING_POLICY_RULE_PREFIX}${id}`,
scope,
models: [],
priorityMode: config.default_policy.priority_mode,
priorityMode: 'provider',
schedulingMode: config.default_policy.scheduling_mode,
policy: createEmptyModelPolicy(DEFAULT_ROUTING_POLICY_MODEL),
}
@@ -48,7 +64,7 @@ function policySignature(policy: RoutingModelPolicy): string {
}
export function readSchedulingPolicies(config: RoutingGroupConfig): SchedulingPolicy[] {
const normalized = normalizeRoutingGroupConfig(config)
const normalized = normalizeProviderSchedulingConfig(config)
const entries: SchedulingPolicy[] = []
const assignedModels = new Set<string>()
const sharedRules = normalized.rules.filter(isGeneratedSchedulingPolicyRule)
@@ -144,10 +160,10 @@ export function validateSchedulingPolicies(entries: SchedulingPolicy[]): string
}
export function writeSchedulingPolicies(config: RoutingGroupConfig, entries: SchedulingPolicy[]): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
const next = normalizeProviderSchedulingConfig(config)
const defaultEntry = entries.find(entry => entry.scope === 'all')
if (defaultEntry) {
next.default_policy.priority_mode = defaultEntry.priorityMode
next.default_policy.priority_mode = 'provider'
next.default_policy.scheduling_mode = defaultEntry.schedulingMode
}
next.model_policies = defaultEntry
@@ -166,7 +182,7 @@ export function writeSchedulingPolicies(config: RoutingGroupConfig, entries: Sch
const action: RoutingSetSchedulingAction = {
...(schedulingIndex >= 0 ? actions[schedulingIndex] as RoutingSetSchedulingAction : {}),
type: 'set_scheduling',
priority_mode: entry.priorityMode,
priority_mode: 'provider',
scheduling_mode: entry.schedulingMode,
}
if (schedulingIndex >= 0) actions[schedulingIndex] = action
@@ -182,14 +198,15 @@ export function writeSchedulingPolicies(config: RoutingGroupConfig, entries: Sch
actions,
})
}
return normalizeRoutingGroupConfig(next)
return normalizeProviderSchedulingConfig(next)
}
export function schedulingPolicyEditorConfig(config: RoutingGroupConfig, entry: SchedulingPolicy): RoutingGroupConfig {
return normalizeRoutingGroupConfig({
disabled_providers: config.disabled_providers,
default_policy: {
...config.default_policy,
priority_mode: entry.priorityMode,
priority_mode: 'provider',
scheduling_mode: entry.schedulingMode,
},
model_policies: [{ ...entry.policy, model: DEFAULT_ROUTING_POLICY_MODEL }],
+8
View File
@@ -1059,6 +1059,10 @@ const legacyExactEnglishMessages: Record<string, string> = {
'尝试调整筛选条件': 'Try adjusting filters',
'暂无提供商,点击右上角添加': 'No providers yet. Add one from the top right.',
'未找到匹配当前筛选条件的提供商': 'No providers match the current filters',
'请先选择适用模型': 'Select the applicable models first',
'在上方调度配置中选择模型后,这里会显示对应的提供商。': 'Select models in the scheduling configuration above to see their providers here.',
'所选模型暂无匹配提供商': 'No providers match the selected models',
'可在上方调整适用模型,或为提供商配置对应模型。': 'Change the applicable models above, or configure the corresponding models for a provider.',
'全部': 'All',
'全部状态': 'All statuses',
'全部类型': 'All types',
@@ -1109,6 +1113,10 @@ const legacyExactEnglishMessages: Record<string, string> = {
'管理员': 'Admin',
'审计管理员': 'Audit admin',
'提供商': 'Provider',
'本组启用': 'Enabled in group',
'本组禁用': 'Disabled in group',
'本组启用提供商': 'Enable provider in this group',
'本组禁用提供商': 'Disable provider in this group',
'模型': 'Model',
'模型:': 'Models:',
'密钥': 'Key',
@@ -1,16 +1,16 @@
import { describe, expect, it } from 'vitest'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type { LocationQuery, RouteLocationNormalizedLoaded } from 'vue-router'
import { buildBreadcrumbs, buildNavigation } from '@/layouts/main-layout/navigation'
import type { MessageKey } from '@/i18n'
const translate = (key: MessageKey) => `tx:${key}`
function route(path: string, name?: string, meta: Record<string, unknown> = {}): RouteLocationNormalizedLoaded {
function route(path: string, name?: string, meta: Record<string, unknown> = {}, query: LocationQuery = {}): RouteLocationNormalizedLoaded {
return {
path,
fullPath: path,
query: {},
query,
hash: '',
name,
params: {},
@@ -96,6 +96,14 @@ describe('main layout navigation builder', () => {
])
})
it('offers one provider destination for management and scheduling', () => {
const navigation = buildNavigation({ canAccessAdmin: true, modules: {}, isModuleActive: () => false })
const destinations = navigation.flatMap(group => group.items.map(item => item.href))
expect(destinations.filter(href => href === '/admin/providers')).toHaveLength(1)
expect(destinations).not.toContain('/admin/routing')
})
it('builds admin navigation with dynamic module menu items sorted by menu order', () => {
const navigation = buildNavigation({
canAccessAdmin: true,
@@ -144,7 +152,7 @@ describe('main layout navigation builder', () => {
)
})
it('builds translated breadcrumbs for settings and routing detail pages', () => {
it('builds translated breadcrumbs for settings and module pages', () => {
const navigation = buildNavigation({
canAccessAdmin: true,
modules: {},
@@ -163,18 +171,6 @@ describe('main layout navigation builder', () => {
{ label: 'tx:breadcrumb.personalSettings' },
])
expect(buildBreadcrumbs({
route: route('/admin/routing/new', 'RoutingProfileCreate'),
navigation,
modules: {},
isNavActive: href => href === '/admin/routing',
t: translate,
})).toEqual([
{ label: 'tx:nav.group.management' },
{ label: 'tx:nav.routing', href: '/admin/routing' },
{ label: 'tx:breadcrumb.routingCreate' },
])
expect(buildBreadcrumbs({
route: route('/dashboard/vscodex'),
navigation: buildNavigation({
@@ -225,4 +221,37 @@ describe('main layout navigation builder', () => {
{ label: '远程控制' },
])
})
it.each<LocationQuery>([{}, { group: 'strategy-a' }, { group: 'new' }])(
'uses the provider directory breadcrumb for every group %o',
(query) => {
const navigation = buildNavigation({ canAccessAdmin: true, modules: {}, isModuleActive: () => false, t: translate })
expect(buildBreadcrumbs({
route: route('/admin/providers', 'ProviderManagement', {}, query),
navigation,
modules: {},
isNavActive: href => href === '/admin/providers',
t: translate,
})).toEqual([
{ label: 'tx:nav.group.management' },
{ label: 'tx:nav.providers' },
])
},
)
it('uses the same provider directory breadcrumb for the default group', () => {
const navigation = buildNavigation({ canAccessAdmin: true, modules: {}, isModuleActive: () => false, t: translate })
expect(buildBreadcrumbs({
route: route('/admin/providers', 'ProviderManagement'),
navigation,
modules: {},
isNavActive: href => href === '/admin/providers',
t: translate,
})).toEqual([
{ label: 'tx:nav.group.management' },
{ label: 'tx:nav.providers' },
])
})
})
@@ -131,7 +131,6 @@ export function buildNavigation(options: {
{ name: t('nav.userManagement'), href: '/admin/users', icon: Users },
{ name: t('nav.providers'), href: '/admin/providers', icon: FolderTree },
{ name: t('nav.modelManagement'), href: '/admin/models', icon: Layers },
{ name: t('nav.routing'), href: '/admin/routing', icon: SlidersHorizontal },
{ name: t('nav.pool'), href: '/admin/pool', icon: Database },
{ name: t('nav.standaloneKeys'), href: '/admin/keys', icon: Key },
{ name: t('nav.walletManagement'), href: '/admin/wallets', icon: Wallet },
@@ -184,18 +183,6 @@ export function buildBreadcrumbs(options: {
]
}
if (route.path.startsWith('/admin/routing/') && route.path !== '/admin/routing') {
return [
{ label: t('nav.group.management') },
{ label: t('nav.routing'), href: '/admin/routing' },
{
label: route.name === 'RoutingProfileCreate'
? t('breadcrumb.routingCreate')
: t('breadcrumb.routingConfig')
}
]
}
for (const group of navigation) {
const activeItem = group.items.find(item => isNavActive(item.href))
if (activeItem) {
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import { adminRoutes } from '../admin'
vi.mock('../helpers', () => ({ view: () => ({ render: () => null }) }))
function createAdminRouter() {
return createRouter({ history: createMemoryHistory(), routes: adminRoutes })
}
describe('provider scheduling routes', () => {
it.each([
['/admin/routing', undefined],
['/admin/routing/new', 'new'],
['/admin/routing/strategy-a', 'strategy-a'],
])('redirects %s into the provider group directory', async (path, group) => {
const router = createAdminRouter()
await router.push(`${path}?view=overview&group=wrong&model=gpt-5#rules`)
expect(router.currentRoute.value.name).toBe('ProviderManagement')
expect(router.currentRoute.value.path).toBe('/admin/providers')
expect(router.currentRoute.value.query).toEqual({ model: 'gpt-5', ...(group ? { group } : {}) })
expect(router.currentRoute.value.hash).toBe('#rules')
expect(router.currentRoute.value.meta).toMatchObject({ requiresAuth: true, requiresAdmin: true })
})
it.each([
['RoutingProfiles', undefined],
['RoutingProfileCreate', 'new'],
['RoutingProfileDetail', 'strategy-a'],
])('preserves navigation by the legacy %s route name', async (name, group) => {
const router = createAdminRouter()
await router.push({ name, ...(name === 'RoutingProfileDetail' ? { params: { groupId: group } } : {}) })
expect(router.currentRoute.value.name).toBe('ProviderManagement')
expect(router.currentRoute.value.query).toEqual({ ...(group ? { group } : {}) })
})
it('keeps the provider overview directly accessible', async () => {
const router = createAdminRouter()
await router.push('/admin/providers')
expect(router.currentRoute.value.name).toBe('ProviderManagement')
expect(router.currentRoute.value.query).toEqual({})
expect(router.currentRoute.value.redirectedFrom).toBeUndefined()
})
})
+15 -3
View File
@@ -71,17 +71,29 @@ export const adminRoutes: RouteRecordRaw[] = [
{
path: 'routing',
name: 'RoutingProfiles',
component: view(() => import('@/views/admin/RoutingProfiles.vue'))
redirect: to => ({
name: 'ProviderManagement',
query: { ...to.query, view: undefined, group: undefined },
hash: to.hash,
})
},
{
path: 'routing/new',
name: 'RoutingProfileCreate',
component: view(() => import('@/views/admin/RoutingProfiles.vue'))
redirect: to => ({
name: 'ProviderManagement',
query: { ...to.query, view: undefined, group: 'new' },
hash: to.hash,
})
},
{
path: 'routing/:groupId',
name: 'RoutingProfileDetail',
component: view(() => import('@/views/admin/RoutingProfiles.vue'))
redirect: to => ({
name: 'ProviderManagement',
query: { ...to.query, view: undefined, group: String(to.params.groupId) },
hash: to.hash,
})
},
{
path: 'health-monitor',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import { createApp, h, nextTick, type App, type PropType } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { createI18n, setI18nLocale } from '@/i18n'
import ProviderManagement from '../ProviderManagement.vue'
import { createSchedulingPolicy, writeSchedulingPolicies } from '@/features/routing/utils/schedulingPolicies'
import { createEmptyRoutingGroupConfig, getDefaultModelPolicy, getModelPolicy, setModelProviderPriorityOverrides, type RoutingGroupConfig, type RoutingModelPolicy } from '@/features/routing/utils/routingPolicy'
const workspace = vi.hoisted(() => ({
groups: {} as Record<string, RoutingGroupConfig>,
busy: false,
selectionReady: true,
updatePriorityPolicy: vi.fn(),
updateDraftConfig: vi.fn(),
refreshGroups: vi.fn().mockResolvedValue(undefined),
ensureSaved: vi.fn().mockResolvedValue(true),
}))
const apiMocks = vi.hoisted(() => ({
getProvidersSummary: vi.fn(),
@@ -43,25 +56,129 @@ vi.mock('@/features/providers/composables/useProviderBalance', () => ({
}),
}))
vi.mock('@/features/providers/components', () => ({
ProviderFormDialog: { render: () => null },
ProviderAuthDialog: { render: () => null },
}))
vi.mock('@/features/providers/components', async () => {
const { defineComponent, h } = await import('vue')
return {
ProviderFormDialog: defineComponent({
props: {
modelValue: Boolean,
provider: { type: Object as PropType<ProviderWithEndpointsSummary | null>, default: null },
routingGroupId: { type: String, default: '' },
routingGroupName: { type: String, default: '' },
},
emits: ['provider-updated', 'update:modelValue'],
setup: (props, { emit }) => () => props.modelValue
? h('button', {
'data-save-edited-provider': '',
'data-routing-group-id': props.routingGroupId,
'data-routing-group-name': props.routingGroupName,
onClick: () => {
emit('provider-updated', { ...props.provider, name: 'Edited provider name' })
emit('update:modelValue', false)
},
}, '保存提供商编辑')
: null,
}),
ProviderAuthDialog: { render: () => null },
}
})
vi.mock('@/features/providers/components/ProviderBatchActionDialog.vue', () => ({
default: { render: () => null },
}))
vi.mock('@/features/providers/components/ProviderDetailDrawer.vue', async () => {
const { h } = await import('vue')
const { defineComponent, h } = await import('vue')
return {
__esModule: true,
default: {
props: ['open', 'providerId'],
setup: (props: { open: boolean; providerId: string }) => () => props.open
? h('div', { 'data-provider-detail': props.providerId })
default: defineComponent({
props: {
open: Boolean,
providerId: { type: String, default: '' },
initialProvider: { type: Object as PropType<ProviderWithEndpointsSummary | null>, default: null },
},
emits: ['edit'],
setup: (props, { emit }) => () => props.open
? h('div', {
'data-provider-detail': props.providerId,
'data-initial-provider-name': props.initialProvider?.name,
}, [
h('button', {
'data-edit-provider': '',
onClick: () => emit('edit', props.initialProvider ?? createProvider({ id: props.providerId })),
}, '编辑提供商'),
])
: null,
},
}),
}
})
vi.mock('@/features/providers/components/ProviderSchedulingView.vue', async () => {
const { computed, defineComponent, h, ref, shallowRef, watch } = await import('vue')
const { useRoute } = await import('vue-router')
const { createEmptyRoutingGroupConfig } = await import('@/features/routing/utils/routingPolicy')
const { readSchedulingPolicies, writeSchedulingPolicies } = await import('@/features/routing/utils/schedulingPolicies')
return {
__esModule: true,
default: defineComponent({
props: { providerRevision: { type: Number, default: 0 } },
emits: ['inspect-provider', 'context-change'],
setup: (_props, { emit, expose, slots }) => {
const route = useRoute()
const groupId = computed(() => typeof route.query.group === 'string' ? route.query.group : 'group-a')
const config = shallowRef<RoutingGroupConfig>(createEmptyRoutingGroupConfig())
const selectedPolicyIndex = ref(0)
watch(groupId, id => { config.value = workspace.groups[id] ?? createEmptyRoutingGroupConfig(); selectedPolicyIndex.value = 0 }, { immediate: true })
const policies = computed(() => readSchedulingPolicies(config.value))
const activePolicy = computed(() => {
const entry = policies.value[selectedPolicyIndex.value]!
return {
policy: workspace.selectionReady ? entry.policy : null,
priorityMode: entry.priorityMode,
schedulingMode: entry.schedulingMode,
scope: workspace.selectionReady ? entry.scope : null,
modelNames: entry.models,
}
})
const providerModelIds = computed(() => activePolicy.value.scope === 'selected'
? activePolicy.value.modelNames.map(name => ({ 'Model One': 'model-1', 'Model Two': 'model-2', 'Model Three': 'model-3' })[name]).filter((id): id is string => Boolean(id))
: undefined)
watch([groupId, config, activePolicy, providerModelIds], () => emit('context-change', {
groupId: groupId.value === 'new' ? null : groupId.value,
groupName: `Group ${groupId.value}`,
config: config.value,
busy: workspace.busy,
activePolicy: activePolicy.value,
providerModelIds: providerModelIds.value,
priorityMode: activePolicy.value.priorityMode,
schedulingMode: activePolicy.value.schedulingMode,
}), { immediate: true })
expose({
updateDraftConfig(value: RoutingGroupConfig) {
workspace.updateDraftConfig(value)
workspace.groups[groupId.value] = value
config.value = value
},
updatePriorityPolicy(value: RoutingModelPolicy) {
workspace.updatePriorityPolicy(value)
const updated = policies.value.map((entry, index) => index === selectedPolicyIndex.value ? { ...entry, policy: value } : entry)
const nextConfig = writeSchedulingPolicies(config.value, updated)
workspace.groups[groupId.value] = nextConfig
config.value = nextConfig
},
refreshGroups: workspace.refreshGroups,
ensureSaved: workspace.ensureSaved,
})
return () => h('section', { 'data-scheduling-group': groupId.value }, [
h('button', {
'data-inspect-scheduled-provider': '',
onClick: () => emit('inspect-provider', 'provider-1'),
}, '查看调度提供商'),
...policies.value.map((_entry, index) => h('button', { 'data-select-policy': index, onClick: () => { selectedPolicyIndex.value = index } }, `配置 ${index + 1}`)),
slots.default?.(),
])
},
}),
}
})
@@ -101,6 +218,7 @@ function createProvider(overrides: Partial<ProviderWithEndpointsSummary> = {}):
let mountedApp: App | null = null
let mountedRoot: HTMLElement | null = null
let mountedRouter: Router | null = null
async function settle() {
for (let index = 0; index < 8; index += 1) {
@@ -109,14 +227,23 @@ async function settle() {
}
}
async function mountView() {
async function mountView(path = '/admin/providers') {
const root = document.createElement('div')
document.body.appendChild(root)
mountedRoot = root
mountedApp = createApp(ProviderManagement)
mountedRouter = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/admin/providers', name: 'ProviderManagement', component: ProviderManagement }],
})
await mountedRouter.push(path)
await mountedRouter.isReady()
mountedApp = createApp({ render: () => h(RouterView) })
mountedApp.use(mountedRouter)
mountedApp.use(createI18n())
mountedApp.mount(root)
await settle()
await vi.waitFor(() => expect(root.querySelector('[data-scheduling-group]')).not.toBeNull())
await settle()
return root
}
@@ -125,6 +252,7 @@ function unmountView() {
mountedRoot?.remove()
mountedApp = null
mountedRoot = null
mountedRouter = null
}
function findButton(root: HTMLElement, title: string): HTMLButtonElement {
@@ -133,11 +261,26 @@ function findButton(root: HTMLElement, title: string): HTMLButtonElement {
return button!
}
async function openPriorityInput(root: HTMLElement, providerName: string): Promise<HTMLInputElement> {
const label = `${providerName} 的组内优先级`
const button = root.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
expect(button, `Missing priority button: ${providerName}`).not.toBeNull()
button!.click()
await nextTick()
const input = root.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`)
expect(input, `Missing priority input: ${providerName}`).not.toBeNull()
return input!
}
beforeEach(() => {
vi.clearAllMocks()
workspace.groups = { 'group-a': createEmptyRoutingGroupConfig(), 'group-b': createEmptyRoutingGroupConfig() }
workspace.busy = false
workspace.selectionReady = true
apiMocks.getProvidersSummary.mockResolvedValue({
items: [createProvider()],
total: 40,
total: 1,
})
apiMocks.getGlobalModels.mockResolvedValue({ models: [{ id: 'model-1', name: 'Model One' }] })
apiMocks.getProvider.mockResolvedValue(createProvider())
@@ -159,7 +302,9 @@ describe('ProviderManagement card view', () => {
it('places the view toggle immediately after refresh and switches layouts without reloading data', async () => {
const root = await mountView()
const toggle = findButton(root, '切换到卡片视图')
const filters = [...root.querySelectorAll('[role="combobox"]')].slice(0, 3)
const filters = [...root.querySelectorAll('[role="combobox"]')]
.filter(filter => filter.closest('.xl\\:hidden'))
expect(filters).toHaveLength(1)
expect(toggle.previousElementSibling).toBe(findButton(root, '刷新'))
expect(toggle.getAttribute('aria-pressed')).toBe('false')
@@ -199,7 +344,9 @@ describe('ProviderManagement card view', () => {
const root = await mountView()
const card = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-1"]')!
const lastCard = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-5"]')!
const [header, content, actions] = Array.from(card.children)
const header = card.firstElementChild!
const content = card.querySelector('.overflow-y-auto')!
const actions = card.lastElementChild!
expect(Array.from(card.classList)).toEqual(expect.arrayContaining(['max-h-96', 'w-full']))
expect(card.classList.contains('max-w-sm')).toBe(false)
@@ -258,33 +405,23 @@ describe('ProviderManagement card view', () => {
expect(localStorage.getItem('aether-provider-card-view')).toBe(String(selected))
})
it('keeps the current search and page when switching views', async () => {
it('keeps the current search and page when switching layouts and filters locally', async () => {
apiMocks.getProvidersSummary.mockResolvedValue({ items: Array.from({ length: 30 }, (_, index) => createProvider({ id: `provider-${index + 1}` })), total: 30 })
const root = await mountView()
const search = root.querySelector<HTMLInputElement>('#provider-search')!
search.value = 'Provider'
search.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => {
expect(apiMocks.getProvidersSummary).toHaveBeenLastCalledWith(
expect.objectContaining({ search: 'Provider' }),
expect.any(Object),
)
})
const secondPage = [...root.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '2')!
await settle()
const secondPage = root.querySelector<HTMLButtonElement>('button[aria-label="第 2 页"]')!
secondPage.click()
await settle()
const requests = apiMocks.getProvidersSummary.mock.calls.length
findButton(root, '切换到卡片视图').click()
await settle()
expect(search.value).toBe('Provider')
expect(root.querySelector('[aria-current="page"]')?.textContent?.trim()).toBe('2')
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requests)
expect(apiMocks.getProvidersSummary).toHaveBeenLastCalledWith(
expect.objectContaining({ search: 'Provider', page: 2 }),
expect.any(Object),
)
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
expect(apiMocks.getProvidersSummary).toHaveBeenCalledWith({ page: 1, page_size: 10000 }, expect.any(Object))
})
it('supports note editing, status actions, and details from cards', async () => {
@@ -304,7 +441,7 @@ describe('ProviderManagement card view', () => {
expect(root.textContent).toContain('Updated note')
expect(root.querySelector('[data-provider-detail]')).toBeNull()
findButton(root, '停用提供商').click()
findButton(root, '全局停用提供商').click()
await settle()
expect(apiMocks.updateProvider).toHaveBeenCalledWith('provider-1', { is_active: false })
expect(root.querySelector('[data-provider-detail]')).toBeNull()
@@ -325,7 +462,7 @@ describe('ProviderManagement card view', () => {
expect(root.querySelector('dl')?.textContent).toContain('账号')
expect(root.textContent).toContain('暂无端点')
expect(findButton(root, '启用提供商')).not.toBeNull()
expect(findButton(root, '全局启用提供商')).not.toBeNull()
})
it('does not display cards during loading or with an empty result', async () => {
@@ -357,6 +494,313 @@ describe('ProviderManagement card view', () => {
})
})
describe('ProviderManagement group directory', () => {
it('opens the unified provider directory directly for a group', async () => {
const root = await mountView('/admin/providers?group=group-b')
expect(root.querySelector('[data-scheduling-group="group-b"]')).not.toBeNull()
expect(root.querySelector('table')).not.toBeNull()
expect(root.querySelector('[aria-label="提供商视图"]')).toBeNull()
expect(apiMocks.getProvidersSummary).toHaveBeenCalledWith({ page: 1, page_size: 10000 }, expect.any(Object))
expect(root.textContent).toContain('$125.00')
})
it.each(['table', 'mobile card', 'grid card'] as const)('keeps the %s group action before details and isolated from global provider state', async layout => {
const providers = mockSortableProviders()
providers[3]!.is_active = false
localStorage.setItem('aether-provider-card-view', String(layout === 'grid card'))
workspace.groups['group-b'] = {
...setModelProviderPriorityOverrides(createEmptyRoutingGroupConfig(), '*', { 'provider-4': 0, 'provider-2': 1, 'provider-3': 2, 'provider-1': 3 }),
disabled_providers: ['provider-4'],
}
const root = await mountView('/admin/providers?group=group-a')
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
await mountedRouter!.push('/admin/providers?group=group-b')
await settle()
expect(providerOrder(root)).toEqual(['provider-4', 'provider-2', 'provider-3', 'provider-1'])
const row = [...root.querySelectorAll<HTMLElement>('[data-provider-sort-id="provider-4"]')]
.find(element => layout === 'table' ? element.closest('table') : !element.closest('table'))!
const toggle = row.querySelector<HTMLButtonElement>('[aria-label="Provider 4 本组启用"]')!
expect(toggle).not.toBeNull()
expect(toggle.nextElementSibling).toBe(findButton(row, '查看详情'))
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(toggle.title).toBe('本组启用提供商')
expect(toggle.textContent?.trim()).toBe('')
expect(row.querySelector('[role="switch"]')).toBeNull()
expect(row.textContent).toContain('本组禁用')
expect(row.textContent).toContain('全局停用')
toggle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
toggle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
toggle.click()
await settle()
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(toggle.title).toBe('本组禁用提供商')
expect(row.textContent).toContain('本组启用')
expect(row.textContent).not.toContain('本组禁用')
expect(row.textContent).toContain('全局停用')
expect(workspace.groups['group-b']!.disabled_providers).toEqual([])
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
expect(workspace.updateDraftConfig).toHaveBeenCalledExactlyOnceWith(workspace.groups['group-b'])
expect(workspace.updatePriorityPolicy).not.toHaveBeenCalled()
expect(providers[3]!.is_active).toBe(false)
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
it('disables group actions while the selected group is busy', async () => {
workspace.busy = true
const root = await mountView()
const toggles = root.querySelectorAll<HTMLButtonElement>('[aria-label="Provider One 本组启用"]')
expect(toggles.length).toBeGreaterThan(0)
for (const toggle of toggles) {
expect(toggle.disabled).toBe(true)
toggle.click()
}
await settle()
expect(workspace.updateDraftConfig).not.toHaveBeenCalled()
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
it('edits the selected group priority from a row without opening details', async () => {
mockSortableProviders()
const root = await mountView()
expect(root.querySelector('input[aria-label$="的组内优先级"]')).toBeNull()
const input = await openPriorityInput(root, 'Provider 4')
expect(root.querySelector('[data-provider-detail]')).toBeNull()
input.value = '0'
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(workspace.updatePriorityPolicy).not.toHaveBeenCalled()
input.blur()
await settle()
expect(providerOrder(root)).toEqual(['provider-4', 'provider-1', 'provider-2', 'provider-3'])
expect(getModelPolicy(workspace.groups['group-a']!, '*').provider_priority_overrides['provider-4']).toBe(0)
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
it('keeps globally or group-disabled providers visible and toggles only group state', async () => {
const providers = mockSortableProviders()
providers[0]!.is_active = false
workspace.groups['group-a']!.disabled_providers = ['provider-2']
const root = await mountView()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
const globallyDisabled = providerElements(root).find(row => row.dataset.providerSortId === 'provider-1')!
const groupDisabled = providerElements(root).find(row => row.dataset.providerSortId === 'provider-2')!
expect(globallyDisabled.textContent).toContain('全局停用')
expect(globallyDisabled.textContent).toContain('本组启用')
expect(groupDisabled.textContent).toContain('全局启用')
expect(groupDisabled.textContent).toContain('本组禁用')
groupDisabled.querySelector<HTMLButtonElement>('[aria-label="Provider 2 本组启用"]')!.click()
await settle()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
expect(groupDisabled.textContent).toContain('本组启用')
expect(workspace.groups['group-a']!.disabled_providers).toEqual([])
globallyDisabled.querySelector<HTMLButtonElement>('[aria-label="Provider 1 本组启用"]')!.click()
await settle()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
expect(globallyDisabled.textContent).toContain('本组禁用')
expect(globallyDisabled.textContent).toContain('全局停用')
expect(providers[0]!.is_active).toBe(false)
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
})
it('changes priority for every model in the top selected configuration while preserving other configurations', async () => {
const providers = mockSortableProviders()
providers[1]!.global_model_ids = ['model-2']
providers[3]!.global_model_ids = ['model-3']
const config = createEmptyRoutingGroupConfig()
workspace.groups['group-a'] = writeSchedulingPolicies(config, [
{ ...createSchedulingPolicy(config), models: ['Model One', 'Model Two'] },
{ ...createSchedulingPolicy(config), models: ['Model Three'] },
])
const root = await mountView()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
expect(root.querySelector('[title="筛选模型"]')).toBeNull()
const input = await openPriorityInput(root, 'Provider 3')
input.value = '0'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await settle()
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4'])
for (const model of ['Model One', 'Model Two']) {
expect(getModelPolicy(workspace.groups['group-a']!, model).provider_priority_overrides['provider-3']).toBe(0)
}
expect(getModelPolicy(workspace.groups['group-a']!, 'Model Three').provider_priority_overrides).toEqual({})
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
expect(workspace.updateDraftConfig).not.toHaveBeenCalled()
root.querySelector<HTMLButtonElement>('[data-select-policy="1"]')!.click()
await settle()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
const otherInput = await openPriorityInput(root, 'Provider 4')
otherInput.value = '2'
otherInput.dispatchEvent(new Event('input', { bubbles: true }))
otherInput.blur()
await settle()
expect(getModelPolicy(workspace.groups['group-a']!, 'Model Three').provider_priority_overrides).toEqual({ 'provider-4': 2 })
expect(getModelPolicy(workspace.groups['group-a']!, 'Model One').provider_priority_overrides).toEqual({ 'provider-3': 0 })
root.querySelector<HTMLButtonElement>('[data-select-policy="0"]')!.click()
await settle()
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4'])
})
it('drags the shared model configuration without splitting its models', async () => {
mockSortableProviders()
const config = createEmptyRoutingGroupConfig()
workspace.groups['group-a'] = writeSchedulingPolicies(config, [
{ ...createSchedulingPolicy(config), models: ['Model One', 'Model Two'] },
])
const root = await mountView()
const { handle } = startProviderDrag(root, 'provider-4', 'provider-1')
await dropProvider(handle)
expect(providerOrder(root)).toEqual(['provider-4', 'provider-1', 'provider-2', 'provider-3'])
const first = getModelPolicy(workspace.groups['group-a']!, 'Model One').provider_priority_overrides
expect(first['provider-4']).toBe(0)
expect(getModelPolicy(workspace.groups['group-a']!, 'Model Two').provider_priority_overrides).toEqual(first)
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
})
it('keeps full-directory pagination when the selected configuration changes', async () => {
const providers = Array.from({ length: 22 }, (_, index) => createProvider({
id: `provider-${index + 1}`, name: `Provider ${index + 1}`, provider_priority: index,
global_model_ids: [index < 11 ? 'model-1' : 'model-2'],
}))
apiMocks.getProvidersSummary.mockResolvedValue({ items: providers, total: providers.length })
const config = createEmptyRoutingGroupConfig()
workspace.groups['group-a'] = writeSchedulingPolicies(config, [
{ ...createSchedulingPolicy(config), models: ['Model One'] },
{ ...createSchedulingPolicy(config), models: ['Model Two'] },
])
localStorage.setItem('provider-management-page-size', '10')
const root = await mountView()
const secondPage = root.querySelector<HTMLButtonElement>('button[aria-label="第 2 页"]')!
secondPage.click()
await settle()
expect(providerOrder(root)).toEqual(providers.slice(10, 20).map(provider => provider.id))
root.querySelector<HTMLButtonElement>('[data-select-policy="1"]')!.click()
await settle()
expect(root.querySelector('[aria-current="page"]')?.textContent?.trim()).toBe('1')
expect(providerOrder(root)).toEqual(providers.slice(0, 10).map(provider => provider.id))
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
})
it('uses provider ranking for legacy Key groups while retaining group enablement', async () => {
mockSortableProviders()
workspace.groups['group-a']!.default_policy.priority_mode = 'global_key'
const root = await mountView()
const priorityButton = root.querySelector<HTMLButtonElement>('button[aria-label="Provider 1 的组内优先级"]')!
expect(priorityButton.disabled).toBe(false)
expect(root.querySelector('[data-provider-drag-handle]')).not.toBeNull()
const input = await openPriorityInput(root, 'Provider 1')
input.value = '7'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await settle()
expect(workspace.groups['group-a']!.default_policy.priority_mode).toBe('provider')
expect(getDefaultModelPolicy(workspace.groups['group-a']!).provider_priority_overrides['provider-1']).toBe(7)
const toggle = providerElements(root)[0]!.querySelector<HTMLButtonElement>('[aria-label="Provider 1 本组启用"]')!
expect(toggle.disabled).toBe(false)
toggle.click()
await settle()
expect(workspace.groups['group-a']!.disabled_providers).toContain('provider-1')
expect(workspace.updatePriorityPolicy).toHaveBeenCalled()
})
it('disables ranking before a configuration is selected without blocking provider creation', async () => {
mockSortableProviders()
workspace.selectionReady = false
const root = await mountView()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
const priorityButton = providerElements(root)[0]!.querySelector<HTMLButtonElement>('button[aria-label$="的组内优先级"]')!
expect(priorityButton.disabled).toBe(true)
const handle = root.querySelector<HTMLButtonElement>('[data-provider-drag-handle]')
expect(handle == null || handle.disabled).toBe(true)
const toggle = providerElements(root)[0]!.querySelector<HTMLButtonElement>('[aria-label="Provider 1 本组启用"]')!
expect(toggle.disabled).toBe(false)
toggle.click()
await settle()
expect(workspace.groups['group-a']!.disabled_providers).toContain('provider-1')
findButton(root, '新增提供商').click()
await settle()
expect(root.querySelector('[data-routing-group-id="group-a"]')).not.toBeNull()
expect(workspace.updatePriorityPolicy).not.toHaveBeenCalled()
})
it('keeps all providers manageable and sortable when the selected model has no matches', async () => {
mockSortableProviders()
const config = createEmptyRoutingGroupConfig()
workspace.groups['group-a'] = writeSchedulingPolicies(config, [
{ ...createSchedulingPolicy(config), models: ['Missing Model'] },
])
const root = await mountView()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
const priorityButton = providerElements(root)[0]!.querySelector<HTMLButtonElement>('button[aria-label$="的组内优先级"]')!
expect(priorityButton.disabled).toBe(false)
expect(providerElements(root)[0]!.querySelector<HTMLButtonElement>('[data-provider-drag-handle]')?.disabled).toBe(false)
const input = await openPriorityInput(root, 'Provider 1')
input.value = '0'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await settle()
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
expect(getModelPolicy(workspace.groups['group-a']!, 'Missing Model').provider_priority_overrides['provider-1']).toBe(0)
const toggle = providerElements(root)[0]!.querySelector<HTMLButtonElement>('[aria-label="Provider 1 本组启用"]')!
expect(toggle.disabled).toBe(false)
toggle.click()
await settle()
expect(workspace.groups['group-a']!.disabled_providers).toContain('provider-1')
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
})
it('collects every API page before applying group priority', async () => {
apiMocks.getProvidersSummary
.mockResolvedValueOnce({ items: [createProvider({ id: 'provider-1' })], total: 2 })
.mockResolvedValueOnce({ items: [createProvider({ id: 'provider-2', name: 'Second Provider' })], total: 2 })
workspace.groups['group-a'] = setModelProviderPriorityOverrides(createEmptyRoutingGroupConfig(), '*', { 'provider-2': 0 })
const root = await mountView()
expect(apiMocks.getProvidersSummary).toHaveBeenNthCalledWith(2, { page: 2, page_size: 10000 }, expect.any(Object))
expect(providerOrder(root)).toEqual(['provider-2', 'provider-1'])
})
it('passes the current group to creation and captures it until the dialog closes', async () => {
const root = await mountView('/admin/providers?group=group-b')
findButton(root, '新增提供商').click()
await settle()
const form = root.querySelector<HTMLElement>('[data-save-edited-provider]')!
expect(form.dataset.routingGroupId).toBe('group-b')
expect(form.dataset.routingGroupName).toBe('Group group-b')
await mountedRouter!.push('/admin/providers?group=group-a')
await settle()
expect(form.dataset.routingGroupId).toBe('group-b')
})
it('does not create a provider before the new group has been saved', async () => {
const root = await mountView('/admin/providers?group=new')
findButton(root, '新增提供商').click()
await settle()
expect(root.querySelector('[data-save-edited-provider]')).toBeNull()
})
it('opens details and applies edited snapshots for providers outside the loaded directory', async () => {
apiMocks.getProvidersSummary.mockResolvedValue({ items: [], total: 0 })
const root = await mountView('/admin/providers?group=group-b')
root.querySelector<HTMLButtonElement>('[data-inspect-scheduled-provider]')!.click()
await vi.waitFor(() => expect(root.querySelector('[data-provider-detail="provider-1"]')).not.toBeNull())
const drawer = root.querySelector<HTMLElement>('[data-provider-detail="provider-1"]')!
expect(drawer.hasAttribute('data-initial-provider-name')).toBe(false)
root.querySelector<HTMLButtonElement>('[data-edit-provider]')!.click()
await vi.waitFor(() => expect(root.querySelector('[data-save-edited-provider]')).not.toBeNull())
root.querySelector<HTMLButtonElement>('[data-save-edited-provider]')!.click()
await settle()
expect(drawer.dataset.initialProviderName).toBe('Edited provider name')
expect(mountedRouter!.currentRoute.value.query).toEqual({ group: 'group-b' })
})
})
function mockSortableProviders() {
const providers = [1, 2, 3, 4].map(index => createProvider({
id: `provider-${index}`,
@@ -404,8 +848,8 @@ async function dropProvider(handle: HTMLButtonElement) {
await settle()
}
describe('ProviderManagement shared display order', () => {
it('drags table rows, synchronizes both card layouts, and leaves scheduling priorities unchanged', async () => {
describe('ProviderManagement group priority ordering', () => {
it('drags table rows, synchronizes both card layouts, and updates only the current group draft', async () => {
const providers = mockSortableProviders()
const root = await mountView()
const { handle, target } = startProviderDrag(root, 'provider-1', 'provider-3')
@@ -423,6 +867,8 @@ describe('ProviderManagement shared display order', () => {
expect(root.querySelector('[data-provider-detail]')).toBeNull()
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
expect(providers.map(provider => provider.provider_priority)).toEqual([10, 20, 30, 40])
expect(getModelPolicy(workspace.groups['group-a']!, '*').provider_priority_overrides).toEqual({ 'provider-2': 0, 'provider-3': 1, 'provider-1': 2, 'provider-4': 3 })
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
findButton(root, '切换到卡片视图').click()
await settle()
@@ -430,7 +876,7 @@ describe('ProviderManagement shared display order', () => {
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
})
it('supports touch dragging on card headers and restores the order after refresh and remount', async () => {
it('supports touch dragging and retains the group draft across resource refresh and layout changes', async () => {
mockSortableProviders()
localStorage.setItem('aether-provider-card-view', 'true')
let root = await mountView()
@@ -438,7 +884,7 @@ describe('ProviderManagement shared display order', () => {
await dropProvider(handle)
const expected = ['provider-4', 'provider-1', 'provider-2', 'provider-3']
expect(providerOrder(root)).toEqual(expected)
expect(JSON.parse(localStorage.getItem('aether-provider-display-order')!)).toEqual(expected)
expect(JSON.parse(localStorage.getItem('aether-provider-display-order') ?? '[]')).toEqual([])
findButton(root, '刷新').click()
await settle()
@@ -467,7 +913,9 @@ describe('ProviderManagement shared display order', () => {
await dropProvider(handle)
expect(providerOrder(root)).toEqual(original)
expect(JSON.parse(localStorage.getItem('aether-provider-display-order')!)).toEqual([])
expect(localStorage.getItem('aether-provider-display-order')).toBeNull()
expect(workspace.updateDraftConfig).not.toHaveBeenCalled()
expect(workspace.updatePriorityPolicy).not.toHaveBeenCalled()
expect(root.querySelector('.opacity-40')).toBeNull()
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
@@ -490,24 +938,23 @@ describe('ProviderManagement shared display order', () => {
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
it('moves only visible providers while preserving filtered-out positions', async () => {
it('keeps hidden providers in the complete group order while dragging a filtered result', async () => {
const providers = mockSortableProviders()
providers[0]!.description = 'filtered'
providers[2]!.description = 'filtered'
const root = await mountView()
apiMocks.getProvidersSummary.mockResolvedValue({ items: [providers[0], providers[2]], total: 2 })
const search = root.querySelector<HTMLInputElement>('#provider-search')!
search.value = 'filtered'
search.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => expect(providerOrder(root)).toEqual(['provider-1', 'provider-3']))
await settle()
expect(providerOrder(root)).toEqual(['provider-1', 'provider-3'])
const { handle } = startProviderDrag(root, 'provider-1', 'provider-3')
await dropProvider(handle)
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1'])
apiMocks.getProvidersSummary.mockResolvedValue({ items: providers, total: 4 })
findButton(root, '重置筛选').click()
await vi.waitFor(() => {
expect(providerOrder(root)).toEqual(['provider-3', 'provider-2', 'provider-1', 'provider-4'])
})
await settle()
expect(providerOrder(root)).toEqual(['provider-2', 'provider-3', 'provider-1', 'provider-4'])
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
})
it('supports keyboard ordering and keeps focus on the moved handle', async () => {
@@ -520,7 +967,7 @@ describe('ProviderManagement shared display order', () => {
expect(providerOrder(root)).toEqual(['provider-2', 'provider-1', 'provider-3', 'provider-4'])
expect(document.activeElement).toBe(handle)
expect(root.querySelector('[role="status"]')?.textContent).toContain('展示顺序已更新')
expect(root.querySelector('[role="status"]')?.textContent).toContain('调度顺序已调整')
expect(root.querySelector('[data-provider-detail]')).toBeNull()
handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }))
@@ -550,30 +997,24 @@ describe('ProviderManagement shared display order', () => {
expect(providerOrder(root).slice(0, 2)).toEqual(['provider-2', 'provider-1'])
const requestsBeforePaging = apiMocks.getProvidersSummary.mock.calls.length
const secondPage = [...root.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '2')!
const secondPage = root.querySelector<HTMLButtonElement>('button[aria-label="第 2 页"]')!
secondPage.click()
await settle()
expect(providerOrder(root)).toEqual(['provider-11', 'provider-12'])
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requestsBeforePaging)
const firstPage = [...root.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '1')!
const firstPage = root.querySelector<HTMLButtonElement>('button[aria-label="第 1 页"]')!
firstPage.click()
await settle()
expect(providerOrder(root).slice(0, 2)).toEqual(['provider-2', 'provider-1'])
expect(JSON.parse(localStorage.getItem('aether-provider-display-order')!))
.toEqual([
'provider-2', 'provider-1', 'provider-3', 'provider-4', 'provider-5', 'provider-6',
'provider-7', 'provider-8', 'provider-9', 'provider-10', 'provider-11', 'provider-12',
])
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
})
it('ignores stale IDs and appends providers that are not in the saved order', async () => {
it('ignores a legacy local display order in favor of the selected group priorities', async () => {
mockSortableProviders()
localStorage.setItem('aether-provider-display-order', JSON.stringify(['deleted-provider', 'provider-3', 'provider-1']))
const root = await mountView()
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4'])
expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4'])
})
it('keeps the dragged provider first after switching to a smaller page size', async () => {
@@ -0,0 +1,366 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, reactive, type App, type ComponentPublicInstance } from 'vue'
import ProviderSchedulingView from '@/features/providers/components/ProviderSchedulingView.vue'
import { createEmptyModelPolicy, createEmptyRoutingGroupConfig, getModelScheduling, savePerModelRoutingConfig, type RoutingModelPolicy } from '@/features/routing/utils/routingPolicy'
import { createSchedulingPolicy, readSchedulingPolicies, writeSchedulingPolicies } from '@/features/routing/utils/schedulingPolicies'
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
const routingApi = vi.hoisted(() => ({
listRoutingGroups: vi.fn(),
updateRoutingGroup: vi.fn(),
createRoutingGroup: vi.fn(),
deleteRoutingGroup: vi.fn(),
}))
const contextChange = vi.hoisted(() => vi.fn())
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
const globalModelsApi = vi.hoisted(() => ({ getGlobalModels: vi.fn() }))
const route = reactive({ name: 'ProviderManagement', query: { group: 'strategy-a' } })
vi.mock('@/api/routing-profiles', () => routingApi)
vi.mock('@/api/global-models', () => globalModelsApi)
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/features/providers/composables/useSchedulingProviderBalance', () => ({ provideSchedulingProviderBalance: vi.fn() }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
vi.mock('@/features/routing/components', async () => ({
RoutingFailoverPolicyEditor: (await import('@/features/routing/components/RoutingFailoverPolicyEditor.vue')).default,
RoutingSchedulingPolicyEditor: (await import('@/features/routing/components/RoutingSchedulingPolicyEditor.vue')).default,
}))
vi.mock('@/features/routing/components/RoutingPriorityPolicyEditor.vue', () => ({ default: { render: () => null } }))
const mounted: Array<{ app: App, root: HTMLElement }> = []
let workspaceInstance: ComponentPublicInstance & { updatePriorityPolicy: (policy: RoutingModelPolicy) => void }
function group(id: string): RoutingGroupRecord {
return {
id,
name: id,
enabled: true,
is_system_default: false,
sort_order: 0,
config_json: createEmptyRoutingGroupConfig(),
version: 1,
created_at: 1,
updated_at: 1,
}
}
async function flush() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
async function mountPage(groups = [group('strategy-a'), group('strategy-b')]) {
routingApi.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
routingApi.updateRoutingGroup.mockImplementation(async (id: string, payload: RoutingGroupUpdateRequest) => ({
...groups.find(entry => entry.id === id),
...payload,
version: 2,
}))
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(ProviderSchedulingView, { onContextChange: contextChange })
workspaceInstance = app.mount(root) as typeof workspaceInstance
mounted.push({ app, root })
await flush()
button(root, '故障转移').click()
await flush()
return root
}
function element<T extends HTMLElement>(root: HTMLElement, selector: string): T {
const found = root.querySelector<T>(selector)
if (!found) throw new Error(`Missing element: ${selector}`)
return found
}
function button(root: HTMLElement, label: string): HTMLButtonElement {
return element(root, `button[aria-label="${label}"]`)
}
async function input(root: HTMLElement, label: string, value: string) {
const field = element<HTMLInputElement | HTMLTextAreaElement>(root, `[aria-label="${label}"]`)
field.value = value
field.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
async function editJson(root: HTMLElement, section: string, value: string) {
button(root, `切到${section} JSON`).click()
await nextTick()
await input(root, `${section} JSON`, value)
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
globalModelsApi.getGlobalModels.mockResolvedValue({ models: [
{ id: 'id-a', name: 'model-a', display_name: '模型 A' },
{ id: 'id-b', name: 'model-b', display_name: '模型 B' },
] })
route.name = 'ProviderManagement'
route.query.group = 'strategy-a'
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
vi.unstubAllGlobals()
})
describe('ProviderSchedulingView failover persistence', () => {
it('enables Save for JSON-only edits and persists both sections together', async () => {
const root = await mountPage()
expect(button(root, '保存调度').disabled).toBe(true)
await editJson(root, '成功转移规则', '[{"pattern":"(?i)capacity"}]')
await editJson(root, '错误终止规则', '[{"status_codes":[400,413]}]')
expect(button(root, '保存调度').disabled).toBe(false)
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules).toEqual({
success_failover_patterns: [{ pattern: '(?i)capacity', status_codes: [] }],
error_stop_patterns: [{ pattern: '', status_codes: [400, 413] }],
})
expect(toast.error).not.toHaveBeenCalled()
expect(button(root, '保存调度').disabled).toBe(true)
})
it('does not submit partial JSON drafts when either section is invalid', async () => {
const root = await mountPage()
await editJson(root, '成功转移规则', '[{"pattern":"capacity"}]')
await editJson(root, '错误终止规则', '{')
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
expect(root.querySelector('[role="alert"]')).not.toBeNull()
await input(root, '错误终止规则 JSON', '[{"status_codes":[429]}]')
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toHaveLength(1)
})
it('discards local rule drafts when navigating to another strategy', async () => {
const root = await mountPage()
await editJson(root, '成功转移规则', '[{"pattern":"only-strategy-a"}]')
route.query.group = 'strategy-b'
await flush()
expect(root.querySelector('textarea[aria-label="成功转移规则 JSON"]')).toBeNull()
expect(button(root, '保存调度').disabled).toBe(true)
await input(root, '全局最大转移次数', '3')
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup.mock.calls[0][0]).toBe('strategy-b')
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toEqual([])
})
it('saves scoped scheduling and global failover edits together without a per-model save', async () => {
const strategy = group('strategy-a')
strategy.config_json = savePerModelRoutingConfig(strategy.config_json, 'model-a')
const root = await mountPage([strategy])
const loadBalance = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '负载均衡')
if (!loadBalance) throw new Error('Missing model scheduling control')
loadBalance.click()
await nextTick()
await input(root, '全局最大转移次数', '5')
button(root, '添加错误终止规则').click()
await nextTick()
await input(root, '终止规则 1 状态码', '429')
expect(button(root, '保存调度').disabled).toBe(false)
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.default_policy.max_transfer_count).toBe(5)
expect(saved.default_policy.failover_rules.error_stop_patterns).toEqual([{ pattern: '', status_codes: [429] }])
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
expect(toast.error).not.toHaveBeenCalled()
})
it('blocks saving an empty scope and persists all selected models in one strategy', async () => {
const root = await mountPage()
const byText = (text: string) => {
const found = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === text)
if (!found) throw new Error(`Missing control: ${text}`)
return found
}
button(root, '区分模型').click()
await flush()
expect(button(root, '保存调度').disabled).toBe(true)
element<HTMLInputElement>(document.body, 'input[aria-label="选择模型 model-a"]').click()
await flush()
element<HTMLInputElement>(root, 'input[aria-label="选择模型 model-a"]').click()
await flush()
expect(button(root, '保存调度').disabled).toBe(true)
for (const model of ['model-a', 'model-b']) {
element<HTMLInputElement>(document.body, `input[aria-label="选择模型 ${model}"]`).click()
await nextTick()
}
byText('负载均衡').click()
await nextTick()
expect(button(root, '保存调度').disabled).toBe(false)
button(root, '保存调度').click()
await flush()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['model-a', 'model-b'])
expect(saved.rules).toHaveLength(1)
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(saved, 'other-model').scheduling_mode).toBe('cache_affinity')
expect(root.querySelectorAll('[aria-label^="选择调度配置 "]')).toHaveLength(1)
expect(button(root, '保存调度').disabled).toBe(true)
})
it('saves one all-model configuration after switching from multiple model-specific configurations', async () => {
const strategy = group('strategy-a')
const first = { ...createSchedulingPolicy(strategy.config_json), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(strategy.config_json), models: ['model-b'], schedulingMode: 'load_balance' as const }
strategy.config_json = writeSchedulingPolicies(strategy.config_json, [first, second])
const root = await mountPage([strategy])
expect(button(root, '区分模型').getAttribute('aria-pressed')).toBe('true')
expect(root.querySelectorAll('[aria-label^="选择调度配置 "]')).toHaveLength(2)
button(root, '全部模型').click()
await flush()
expect(root.querySelectorAll('[aria-label^="选择调度配置 "]')).toHaveLength(0)
expect(root.querySelector('[role="group"][aria-label="调度策略"]')).not.toBeNull()
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
expect(button(root, '保存调度').disabled).toBe(false)
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledOnce()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(readSchedulingPolicies(saved)).toHaveLength(1)
expect(readSchedulingPolicies(saved)[0]).toMatchObject({ scope: 'all', models: [], schedulingMode: 'fixed_order' })
expect(saved.rules).toEqual([])
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['*'])
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(saved, 'future-model').scheduling_mode).toBe('fixed_order')
expect(button(root, '保存调度').disabled).toBe(true)
expect(toast.error).not.toHaveBeenCalled()
})
})
describe('ProviderSchedulingView inline settings drafts', () => {
it('preserves JSON drafts while failover settings are collapsed and saves from the header', async () => {
const root = await mountPage()
await editJson(root, '成功转移规则', '[{"pattern":"keep-after-collapse"}]')
button(root, '故障转移').click()
await flush()
expect(button(root, '故障转移').getAttribute('aria-expanded')).toBe('false')
expect(root.querySelector('[role="dialog"]')).toBeNull()
expect(element<HTMLTextAreaElement>(root, '[aria-label="成功转移规则 JSON"]').value).toContain('keep-after-collapse')
expect(button(root, '保存调度').disabled).toBe(false)
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns[0].pattern).toBe('keep-after-collapse')
})
it('reveals invalid failover JSON on save without changing advanced settings or dropping the draft', async () => {
const root = await mountPage()
await editJson(root, '错误终止规则', '{')
button(root, '故障转移').click()
await flush()
expect(button(root, '故障转移').getAttribute('aria-expanded')).toBe('false')
expect(element<HTMLTextAreaElement>(root, '[aria-label="错误终止规则 JSON"]').value).toBe('{')
button(root, '保存调度').click()
await flush()
expect(button(root, '故障转移').getAttribute('aria-expanded')).toBe('true')
expect(button(root, '高级设置').getAttribute('aria-expanded')).toBe('false')
expect(root.querySelector('[role="dialog"]')).toBeNull()
expect(root.querySelector('[role="alert"]')).not.toBeNull()
expect(element<HTMLTextAreaElement>(root, '[aria-label="错误终止规则 JSON"]').value).toBe('{')
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
})
})
describe('ProviderSchedulingView top-level scheduling selection', () => {
it.each(['selected', 'all'] as const)('keeps the second %s configuration selected after saving its directory priority', async (scope) => {
const strategy = group('strategy-a')
const first = { ...createSchedulingPolicy(strategy.config_json), models: ['model-a'] }
const second = { ...createSchedulingPolicy(strategy.config_json, scope), models: scope === 'selected' ? ['model-b'] : [] }
strategy.config_json = writeSchedulingPolicies(strategy.config_json, [first, second])
const root = await mountPage([strategy])
button(root, '选择调度配置 2').click()
await flush()
workspaceInstance.updatePriorityPolicy({ ...second.policy, provider_priority_overrides: { 'provider-b': 7 } })
await flush()
button(root, '保存调度').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledOnce()
expect(button(root, '选择调度配置 2').getAttribute('aria-pressed')).toBe('true')
expect(contextChange.mock.lastCall?.[0].activePolicy).toMatchObject({
scope,
modelNames: second.models,
policy: { provider_priority_overrides: { 'provider-b': 7 } },
})
expect(contextChange.mock.lastCall?.[0].providerModelIds).toEqual(scope === 'selected' ? ['id-b'] : undefined)
expect(button(root, '保存调度').disabled).toBe(true)
})
it('emits all selected model IDs and applies directory priorities to the whole selection', async () => {
const strategy = group('strategy-a')
strategy.config_json = writeSchedulingPolicies(strategy.config_json, [{ ...createSchedulingPolicy(strategy.config_json), models: ['model-a', 'model-b'] }])
const root = await mountPage([strategy])
const context = contextChange.mock.lastCall?.[0]
expect(context.providerModelIds).toEqual(['id-a', 'id-b'])
expect(context.activePolicy.modelNames).toEqual(['model-a', 'model-b'])
expect(context.priorityMode).toBe('provider')
workspaceInstance.updatePriorityPolicy({ ...createEmptyModelPolicy('*'), provider_priority_overrides: { 'provider-a': 8 } })
await flush()
button(root, '保存调度').click()
await flush()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.model_policies.map((policy: RoutingModelPolicy) => policy.provider_priority_overrides)).toEqual([{ 'provider-a': 8 }, { 'provider-a': 8 }])
})
it('loads and saves legacy Key configuration as provider scheduling without remotely saving on open', async () => {
const strategy = group('strategy-a')
const selected = { ...createSchedulingPolicy(strategy.config_json), models: ['model-a'], policy: { ...createEmptyModelPolicy('*'), key_priority_overrides: { legacyKey: 4 }, provider_priority_overrides: { 'provider-a': 2 } } }
strategy.config_json = writeSchedulingPolicies(strategy.config_json, [selected])
strategy.config_json.default_policy.priority_mode = 'global_key'
for (const rule of strategy.config_json.rules) {
for (const action of rule.actions) {
if (action && typeof action === 'object' && 'type' in action && action.type === 'set_scheduling') Object.assign(action, { priority_mode: 'global_key' })
}
}
const root = await mountPage([strategy])
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
expect(button(root, '保存调度').disabled).toBe(true)
expect(contextChange.mock.lastCall?.[0].priorityMode).toBe('provider')
expect(contextChange.mock.lastCall?.[0].config.default_policy.priority_mode).toBe('provider')
expect(getModelScheduling(contextChange.mock.lastCall?.[0].config, 'model-a').priority_mode).toBe('provider')
expect(root.querySelector('[aria-label="Key 优先级排序"]')).toBeNull()
await input(root, '全局最大转移次数', '3')
button(root, '保存调度').click()
await flush()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.default_policy.priority_mode).toBe('provider')
expect(getModelScheduling(saved, 'model-a').priority_mode).toBe('provider')
expect(saved.model_policies[0].key_priority_overrides).toEqual({ legacyKey: 4 })
expect(saved.model_policies[0].provider_priority_overrides).toEqual({ 'provider-a': 2 })
})
it('keeps the expanded provider configuration without a Key sorting surface', async () => {
const root = await mountPage()
expect(root.querySelector('[aria-label="Key 优先级排序"]')).toBeNull()
expect(contextChange.mock.lastCall?.[0].priorityMode).toBe('provider')
expect(contextChange.mock.lastCall?.[0].activePolicy.priorityMode).toBe('provider')
expect(contextChange.mock.lastCall?.[0].providerModelIds).toBeUndefined()
button(root, '故障转移').click()
await flush()
expect(root.querySelector('[aria-label="策略调度配置"]')).not.toBeNull()
const combined = element(root, '[aria-label="策略分组与调度配置"]')
expect(combined.contains(element(root, 'button[role="combobox"][aria-label="当前调度策略"]'))).toBe(true)
expect(combined.contains(element(root, '[aria-label="策略调度配置"]'))).toBe(true)
expect(combined.querySelector('h3')?.textContent?.trim()).not.toBe('调度配置')
expect(root.querySelector('[aria-label="Key 优先级排序"]')).toBeNull()
})
})
@@ -1,247 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, reactive, type App } from 'vue'
import RoutingProfiles from '../RoutingProfiles.vue'
import { createEmptyRoutingGroupConfig, getModelScheduling, savePerModelRoutingConfig } from '@/features/routing/utils/routingPolicy'
import { createSchedulingPolicy, readSchedulingPolicies, writeSchedulingPolicies } from '@/features/routing/utils/schedulingPolicies'
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
const routingApi = vi.hoisted(() => ({
listRoutingGroups: vi.fn(),
updateRoutingGroup: vi.fn(),
createRoutingGroup: vi.fn(),
deleteRoutingGroup: vi.fn(),
}))
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
const globalModelsApi = vi.hoisted(() => ({ getGlobalModels: vi.fn() }))
const route = reactive({ name: 'RoutingProfileDetail', params: { groupId: 'strategy-a' } })
vi.mock('@/api/routing-profiles', () => routingApi)
vi.mock('@/api/global-models', () => globalModelsApi)
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
vi.mock('@/features/routing/components', async () => ({
RoutingFailoverPolicyEditor: (await import('@/features/routing/components/RoutingFailoverPolicyEditor.vue')).default,
RoutingSchedulingPolicyEditor: (await import('@/features/routing/components/RoutingSchedulingPolicyEditor.vue')).default,
}))
vi.mock('@/features/routing/components/RoutingPriorityPolicyEditor.vue', () => ({ default: { render: () => null } }))
const mounted: Array<{ app: App, root: HTMLElement }> = []
function group(id: string): RoutingGroupRecord {
return {
id,
name: id,
enabled: true,
is_system_default: false,
sort_order: 0,
config_json: createEmptyRoutingGroupConfig(),
version: 1,
created_at: 1,
updated_at: 1,
}
}
async function flush() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
async function mountPage(groups = [group('strategy-a'), group('strategy-b')]) {
routingApi.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
routingApi.updateRoutingGroup.mockImplementation(async (id: string, payload: RoutingGroupUpdateRequest) => ({
...groups.find(entry => entry.id === id),
...payload,
version: 2,
}))
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(RoutingProfiles)
app.mount(root)
mounted.push({ app, root })
await flush()
return root
}
function element<T extends HTMLElement>(root: HTMLElement, selector: string): T {
const found = root.querySelector<T>(selector)
if (!found) throw new Error(`Missing element: ${selector}`)
return found
}
function button(root: HTMLElement, label: string): HTMLButtonElement {
return element(root, `button[aria-label="${label}"]`)
}
async function input(root: HTMLElement, label: string, value: string) {
const field = element<HTMLInputElement | HTMLTextAreaElement>(root, `[aria-label="${label}"]`)
field.value = value
field.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
async function editJson(root: HTMLElement, section: string, value: string) {
button(root, `切到${section} JSON`).click()
await nextTick()
await input(root, `${section} JSON`, value)
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
globalModelsApi.getGlobalModels.mockResolvedValue({ models: [
{ id: 'id-a', name: 'model-a', display_name: '模型 A' },
{ id: 'id-b', name: 'model-b', display_name: '模型 B' },
] })
route.name = 'RoutingProfileDetail'
route.params.groupId = 'strategy-a'
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
vi.unstubAllGlobals()
})
describe('RoutingProfiles failover persistence', () => {
it('enables Save for JSON-only edits and persists both sections together', async () => {
const root = await mountPage()
expect(button(root, '保存').disabled).toBe(true)
await editJson(root, '成功转移规则', '[{"pattern":"(?i)capacity"}]')
await editJson(root, '错误终止规则', '[{"status_codes":[400,413]}]')
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules).toEqual({
success_failover_patterns: [{ pattern: '(?i)capacity', status_codes: [] }],
error_stop_patterns: [{ pattern: '', status_codes: [400, 413] }],
})
expect(toast.error).not.toHaveBeenCalled()
expect(button(root, '保存').disabled).toBe(true)
})
it('does not submit partial JSON drafts when either section is invalid', async () => {
const root = await mountPage()
await editJson(root, '成功转移规则', '[{"pattern":"capacity"}]')
await editJson(root, '错误终止规则', '{')
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
expect(root.querySelector('[role="alert"]')).not.toBeNull()
await input(root, '错误终止规则 JSON', '[{"status_codes":[429]}]')
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toHaveLength(1)
})
it('discards local rule drafts when navigating to another strategy', async () => {
const root = await mountPage()
await editJson(root, '成功转移规则', '[{"pattern":"only-strategy-a"}]')
route.params.groupId = 'strategy-b'
await flush()
expect(root.querySelector('textarea[aria-label="成功转移规则 JSON"]')).toBeNull()
expect(button(root, '保存').disabled).toBe(true)
await input(root, '全局最大转移次数', '3')
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup.mock.calls[0][0]).toBe('strategy-b')
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toEqual([])
})
it('saves scoped scheduling and global failover edits together without a per-model save', async () => {
const strategy = group('strategy-a')
strategy.config_json = savePerModelRoutingConfig(strategy.config_json, 'model-a')
const root = await mountPage([strategy])
const loadBalance = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '负载均衡')
if (!loadBalance) throw new Error('Missing model scheduling control')
loadBalance.click()
await nextTick()
await input(root, '全局最大转移次数', '5')
button(root, '添加错误终止规则').click()
await nextTick()
await input(root, '终止规则 1 状态码', '429')
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.default_policy.max_transfer_count).toBe(5)
expect(saved.default_policy.failover_rules.error_stop_patterns).toEqual([{ pattern: '', status_codes: [429] }])
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
expect(toast.error).not.toHaveBeenCalled()
})
it('blocks saving an empty scope and persists all selected models in one strategy', async () => {
const root = await mountPage()
const byText = (text: string) => {
const found = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === text)
if (!found) throw new Error(`Missing control: ${text}`)
return found
}
button(root, '区分模型').click()
await flush()
expect(button(root, '保存').disabled).toBe(true)
element<HTMLInputElement>(document.body, 'input[aria-label="选择模型 model-a"]').click()
await flush()
button(document.body, '清空已选').click()
await flush()
expect(button(root, '保存').disabled).toBe(true)
for (const model of ['model-a', 'model-b']) {
element<HTMLInputElement>(document.body, `input[aria-label="选择模型 ${model}"]`).click()
await nextTick()
}
const done = [...document.querySelectorAll<HTMLButtonElement>('[aria-label="全局模型选择列表"] button')]
.find(control => control.textContent?.trim() === '完成选择')
done?.click()
await flush()
byText('负载均衡').click()
await nextTick()
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['model-a', 'model-b'])
expect(saved.rules).toHaveLength(1)
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(saved, 'other-model').scheduling_mode).toBe('cache_affinity')
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
expect(button(root, '保存').disabled).toBe(true)
})
it('saves one all-model configuration after switching from multiple model-specific configurations', async () => {
const strategy = group('strategy-a')
const first = { ...createSchedulingPolicy(strategy.config_json), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(strategy.config_json), models: ['model-b'], schedulingMode: 'load_balance' as const }
strategy.config_json = writeSchedulingPolicies(strategy.config_json, [first, second])
const root = await mountPage([strategy])
expect(button(root, '区分模型').getAttribute('aria-pressed')).toBe('true')
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(2)
button(root, '全部模型').click()
await flush()
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledOnce()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(readSchedulingPolicies(saved)).toHaveLength(1)
expect(readSchedulingPolicies(saved)[0]).toMatchObject({ scope: 'all', models: [], schedulingMode: 'fixed_order' })
expect(saved.rules).toEqual([])
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['*'])
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(saved, 'future-model').scheduling_mode).toBe('fixed_order')
expect(button(root, '保存').disabled).toBe(true)
expect(toast.error).not.toHaveBeenCalled()
})
})