mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
feat: unify provider scheduling workspace
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -159,6 +159,7 @@ export async function updateProvider(
|
||||
*/
|
||||
export async function createProvider(
|
||||
data: {
|
||||
routing_group_id?: string
|
||||
name: string
|
||||
provider_type?: ProviderType
|
||||
description?: string
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface RoutingGroupCreateRequest {
|
||||
}
|
||||
|
||||
export interface RoutingGroupUpdateRequest {
|
||||
expected_version?: number
|
||||
name?: string
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+78
@@ -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()
|
||||
})
|
||||
})
|
||||
+354
@@ -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)
|
||||
})
|
||||
})
|
||||
+129
@@ -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)
|
||||
})
|
||||
})
|
||||
+12
-33
@@ -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)
|
||||
})
|
||||
})
|
||||
+15
-1
@@ -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 }],
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user