From d28dd89039ac208594b33bdf94aae0364d3a982f Mon Sep 17 00:00:00 2001 From: elky Date: Wed, 9 Sep 2026 15:51:51 +0800 Subject: [PATCH] fix(providers): restore legacy endpoint health defaults --- .../src/handlers/admin/provider/crud/reads.rs | 5 +- .../handlers/admin/provider/crud/writes.rs | 5 +- .../admin/provider/summary/aggregates.rs | 38 +-- .../handlers/admin/provider/summary/value.rs | 7 +- .../admin/request/provider/builders.rs | 2 +- .../src/handlers/shared/catalog.rs | 39 +++ .../src/tests/control/admin/providers.rs | 84 ++++-- .../tests/control/admin/providers/health.rs | 269 ++++++++++++++++++ docs/api/provider-health-summary.md | 32 ++- .../ProviderKeyActionCluster.spec.ts | 23 ++ .../provider-endpoint-health.spec.ts | 19 +- 11 files changed, 460 insertions(+), 63 deletions(-) create mode 100644 apps/aether-gateway/src/tests/control/admin/providers/health.rs diff --git a/apps/aether-gateway/src/handlers/admin/provider/crud/reads.rs b/apps/aether-gateway/src/handlers/admin/provider/crud/reads.rs index 40d192e7b..ad191ec19 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/crud/reads.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/crud/reads.rs @@ -103,10 +103,11 @@ pub(crate) async fn maybe_build_local_admin_provider_reads_response( .build_admin_provider_summary_payload(&provider_id) .await { - Some(payload) => Json(payload).into_response(), - None => build_admin_provider_not_found_response(format!( + Ok(Some(payload)) => Json(payload).into_response(), + Ok(None) => build_admin_provider_not_found_response(format!( "Provider {provider_id} 不存在" )), + Err(_) => build_admin_providers_data_unavailable_response(), }, )); } diff --git a/apps/aether-gateway/src/handlers/admin/provider/crud/writes.rs b/apps/aether-gateway/src/handlers/admin/provider/crud/writes.rs index fa4b96728..24ec9591a 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/crud/writes.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/crud/writes.rs @@ -173,16 +173,17 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response( .build_admin_provider_summary_payload(&provider_id) .await { - Some(payload) => attach_admin_audit_response( + Ok(Some(payload)) => attach_admin_audit_response( Json(payload).into_response(), "admin_provider_updated", "update_provider", "provider", &provider_id, ), - None => build_admin_provider_not_found_response(format!( + Ok(None) => build_admin_provider_not_found_response(format!( "Provider {provider_id} 不存在" )), + Err(_) => build_admin_providers_data_unavailable_response(), }, )); } diff --git a/apps/aether-gateway/src/handlers/admin/provider/summary/aggregates.rs b/apps/aether-gateway/src/handlers/admin/provider/summary/aggregates.rs index 4922407d6..48b635e39 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/summary/aggregates.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/summary/aggregates.rs @@ -1,5 +1,6 @@ use super::value::build_admin_provider_summary_value; use crate::handlers::admin::request::AdminAppState; +use crate::GatewayError; use aether_data_contracts::repository::provider_catalog::{ StoredProviderCatalogEndpoint, StoredProviderCatalogKey, }; @@ -10,19 +11,23 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) async fn build_admin_provider_summary_payload( state: &AdminAppState<'_>, provider_id: &str, -) -> Option { +) -> Result, GatewayError> { let state = state.as_ref(); if !state.has_provider_catalog_data_reader() { - return None; + return Err(GatewayError::Internal( + "Admin provider catalog data unavailable".to_string(), + )); } let provider_ids = vec![provider_id.to_string()]; - let provider = state + let Some(provider) = state .read_provider_catalog_providers_by_ids(&provider_ids) - .await - .ok()? + .await? .into_iter() - .next()?; + .next() + else { + return Ok(None); + }; let ( endpoints_result, keys_result, @@ -36,8 +41,8 @@ pub(crate) async fn build_admin_provider_summary_payload( state.list_provider_model_stats(&provider_ids), state.list_active_global_model_ids_by_provider_ids(&provider_ids), ); - let endpoints = endpoints_result.ok().unwrap_or_default(); - let keys = keys_result.ok().unwrap_or_default(); + let endpoints = endpoints_result?; + let keys = keys_result?; let quota_snapshot = quota_snapshot_result.ok().flatten(); let model_stats = model_stats_result .ok() @@ -57,7 +62,7 @@ pub(crate) async fn build_admin_provider_summary_payload( .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - Some(build_admin_provider_summary_value( + Ok(Some(build_admin_provider_summary_value( &provider, &endpoints, &keys, @@ -65,7 +70,7 @@ pub(crate) async fn build_admin_provider_summary_payload( model_stats.as_ref(), active_global_model_ids, now_unix_secs, - )) + ))) } pub(crate) async fn build_admin_providers_summary_payload( @@ -94,11 +99,7 @@ pub(crate) async fn build_admin_providers_summary_payload( normalized_api_format != "all" && !normalized_api_format.is_empty(); let requires_model_filter = normalized_model_id != "all" && !normalized_model_id.is_empty(); - let mut providers = state - .list_provider_catalog_providers(false) - .await - .ok() - .unwrap_or_default(); + let mut providers = state.list_provider_catalog_providers(false).await.ok()?; let all_provider_ids = providers .iter() .map(|provider| provider.id.clone()) @@ -109,8 +110,7 @@ pub(crate) async fn build_admin_providers_summary_payload( state .list_provider_catalog_endpoints_by_provider_ids(&all_provider_ids) .await - .ok() - .unwrap_or_default() + .ok()? }; let active_global_model_refs = if !requires_model_filter || all_provider_ids.is_empty() { Vec::new() @@ -202,8 +202,8 @@ pub(crate) async fn build_admin_providers_summary_payload( state.list_active_global_model_ids_by_provider_ids(&provider_ids), ); ( - endpoints_result.ok().unwrap_or_default(), - keys_result.ok().unwrap_or_default(), + endpoints_result.ok()?, + keys_result.ok()?, model_stats_result.ok().unwrap_or_default(), active_global_model_refs_result.ok().unwrap_or_default(), ) diff --git a/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs b/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs index db68b61da..ea2a5dd51 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs @@ -95,8 +95,11 @@ pub(crate) fn build_admin_provider_summary_value( let scores = endpoint_keys .iter() .filter(|key| endpoint.is_active && key.is_active) - .filter_map(|key| provider_key_health_score(key, &endpoint.api_format)) - .filter(|score| score.is_finite()) + .map(|key| { + provider_key_health_score(key, &endpoint.api_format) + .filter(|score| score.is_finite()) + .unwrap_or(1.0) + }) .collect::>(); let health_score = (!scores.is_empty()).then(|| scores.iter().sum::() / scores.len() as f64); diff --git a/apps/aether-gateway/src/handlers/admin/request/provider/builders.rs b/apps/aether-gateway/src/handlers/admin/request/provider/builders.rs index fe86e0491..54ebd8899 100644 --- a/apps/aether-gateway/src/handlers/admin/request/provider/builders.rs +++ b/apps/aether-gateway/src/handlers/admin/request/provider/builders.rs @@ -141,7 +141,7 @@ impl<'a> AdminAppState<'a> { pub(crate) async fn build_admin_provider_summary_payload( &self, provider_id: &str, - ) -> Option { + ) -> Result, GatewayError> { crate::handlers::admin::provider::summary::build_admin_provider_summary_payload( self, provider_id, diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index c0649d493..736640b3e 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -3074,6 +3074,45 @@ mod tests { .expect("key transport should build") } + #[test] + fn admin_provider_key_health_response_preserves_v0_7_13_defaults() { + let state = AppState::new().expect("gateway should build"); + for (health, expected_score) in [ + (None, json!(1.0)), + (Some(json!({})), json!(1.0)), + ( + Some(json!({"openai:chat": {"consecutive_failures": 0}})), + json!(1.0), + ), + ( + Some(json!({"openai:chat": {"health_score": 0.0}})), + json!(0.0), + ), + ( + Some(json!({"openai:chat": {"health_score": 1.0}})), + json!(1.0), + ), + ( + Some(json!({ + "openai:chat": {"health_score": 0.25}, + "openai:responses": {"health_score": 0.75}, + })), + json!(0.25), + ), + ] { + let mut key = sample_catalog_key(); + key.health_by_format = health; + let payload = build_admin_provider_key_response( + &state, + &key, + "openai", + &["openai:chat".to_string()], + 1_000, + ); + assert_eq!(payload["health_score"], expected_score); + } + } + #[test] fn responses_key_scope_covers_search_in_one_direction() { let mut responses_key = sample_catalog_key(); diff --git a/apps/aether-gateway/src/tests/control/admin/providers.rs b/apps/aether-gateway/src/tests/control/admin/providers.rs index ff8d3ef3b..eb9467ed5 100644 --- a/apps/aether-gateway/src/tests/control/admin/providers.rs +++ b/apps/aether-gateway/src/tests/control/admin/providers.rs @@ -40,6 +40,8 @@ use crate::data::GatewayDataState; const ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin provider catalog data unavailable"; +mod health; + async fn provider_health_summary( endpoints: &[StoredProviderCatalogEndpoint], keys: &[StoredProviderCatalogKey], @@ -170,7 +172,7 @@ async fn admin_provider_summary_health_ignores_disabled_keys() { } #[tokio::test] -async fn admin_provider_summary_health_does_not_inflate_observed_scores_with_missing_data() { +async fn admin_provider_summary_health_averages_v0_7_13_defaults_with_observed_scores() { let endpoint = sample_endpoint( "endpoint-chat", "provider-openai", @@ -183,21 +185,52 @@ async fn admin_provider_summary_health_does_not_inflate_observed_scores_with_mis sample_key("key-unobserved", "provider-openai", "openai:chat", "test"), sample_key("key-other-format", "provider-openai", "openai:chat", "test") .with_health_fields( - Some(json!({"openai:responses": {"health_score": 1.0}})), + Some(json!({"openai:responses": {"health_score": 0.0}})), None, ), ]; let payload = provider_health_summary(&[endpoint], &keys).await; - assert_eq!(payload["endpoint_health_details"][0]["health_score"], 0.2); + let expected_score = (0.2 + 1.0 + 1.0) / 3.0; + assert_eq!( + payload["endpoint_health_details"][0]["health_score"], + expected_score + ); assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 3); - assert_eq!(payload["avg_health_score"], 0.2); - assert_eq!(payload["unhealthy_endpoints"], 1); + assert_eq!(payload["avg_health_score"], expected_score); + assert_eq!(payload["unhealthy_endpoints"], 0); } #[tokio::test] -async fn admin_provider_summary_health_is_unknown_without_active_observations() { +async fn admin_provider_summary_health_defaults_unobserved_enabled_keys_to_one() { + let endpoint = sample_endpoint( + "endpoint-chat", + "provider-openai", + "openai:chat", + "https://api.openai.example", + ); + for health in [ + None, + Some(json!({})), + Some(json!({"openai:chat": {"consecutive_failures": 0}})), + Some(json!({"openai:chat": {"health_score": null}})), + Some(json!({"openai:chat": {"health_score": "invalid"}})), + Some(json!({"openai:responses": {"health_score": 0.0}})), + ] { + let key = sample_key("key-unobserved", "provider-openai", "openai:chat", "test") + .with_health_fields(health, None); + let payload = provider_health_summary(std::slice::from_ref(&endpoint), &[key]).await; + + assert_eq!(payload["endpoint_health_details"][0]["health_score"], 1.0); + assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 1); + assert_eq!(payload["avg_health_score"], 1.0); + assert_eq!(payload["unhealthy_endpoints"], 0); + } +} + +#[tokio::test] +async fn admin_provider_summary_health_is_unknown_without_enabled_keys() { let endpoint = sample_endpoint( "endpoint-chat", "provider-openai", @@ -207,16 +240,7 @@ async fn admin_provider_summary_health_is_unknown_without_active_observations() let mut disabled_key = sample_key("key-disabled", "provider-openai", "openai:chat", "test") .with_health_fields(Some(json!({"openai:chat": {"health_score": 0.2}})), None); disabled_key.is_active = false; - for keys in [ - Vec::new(), - vec![sample_key( - "key-unobserved", - "provider-openai", - "openai:chat", - "test", - )], - vec![disabled_key], - ] { + for keys in [Vec::new(), vec![disabled_key]] { let payload = provider_health_summary(std::slice::from_ref(&endpoint), &keys).await; assert_eq!( @@ -233,7 +257,7 @@ async fn admin_provider_summary_health_is_unknown_without_active_observations() } #[tokio::test] -async fn admin_provider_summary_health_excludes_disabled_and_unobserved_endpoints() { +async fn admin_provider_summary_health_excludes_disabled_endpoints_and_defaults_unobserved_ones() { let mut disabled_endpoint = sample_endpoint( "endpoint-disabled", "provider-openai", @@ -279,16 +303,22 @@ async fn admin_provider_summary_health_excludes_disabled_and_unobserved_endpoint let payload = provider_health_summary(&endpoints, &keys).await; - assert_eq!(payload["endpoint_health_details"][0]["health_score"], 0.8); - assert_eq!( - payload["endpoint_health_details"][1]["health_score"], - json!(null) - ); - assert_eq!( - payload["endpoint_health_details"][2]["health_score"], - json!(null) - ); - assert_eq!(payload["avg_health_score"], 0.8); + let details = payload["endpoint_health_details"] + .as_array() + .expect("endpoint health details should be an array"); + for (api_format, health_score, is_active) in [ + ("openai:chat", json!(0.8), true), + ("openai:responses", json!(null), false), + ("openai:embedding", json!(1.0), true), + ] { + let detail = details + .iter() + .find(|detail| detail["api_format"] == api_format) + .expect("endpoint health detail should exist"); + assert_eq!(detail["health_score"], health_score, "{api_format}"); + assert_eq!(detail["is_active"], is_active, "{api_format}"); + } + assert_eq!(payload["avg_health_score"], 0.9); assert_eq!(payload["unhealthy_endpoints"], 0); } diff --git a/apps/aether-gateway/src/tests/control/admin/providers/health.rs b/apps/aether-gateway/src/tests/control/admin/providers/health.rs new file mode 100644 index 000000000..3f1072ace --- /dev/null +++ b/apps/aether-gateway/src/tests/control/admin/providers/health.rs @@ -0,0 +1,269 @@ +use super::*; +use aether_data_contracts::repository::provider_catalog::{ + ProviderCatalogKeyListQuery, StoredProviderCatalogKeyMaintenanceSummary, + StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats, +}; +use aether_data_contracts::DataLayerError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FailedRead { + Providers, + Endpoints, + Keys, +} + +struct FailingSummaryRepository { + inner: InMemoryProviderCatalogReadRepository, + failed_read: FailedRead, +} + +impl FailingSummaryRepository { + fn check(&self, operation: FailedRead) -> Result<(), DataLayerError> { + if self.failed_read == operation { + return Err(DataLayerError::InvalidConfiguration( + "injected summary read failure".to_string(), + )); + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl ProviderCatalogReadRepository for FailingSummaryRepository { + async fn list_providers( + &self, + active_only: bool, + ) -> Result, DataLayerError> { + self.check(FailedRead::Providers)?; + self.inner.list_providers(active_only).await + } + + async fn list_providers_by_ids( + &self, + provider_ids: &[String], + ) -> Result, DataLayerError> { + self.check(FailedRead::Providers)?; + self.inner.list_providers_by_ids(provider_ids).await + } + + async fn list_endpoints_by_ids( + &self, + endpoint_ids: &[String], + ) -> Result, DataLayerError> { + self.inner.list_endpoints_by_ids(endpoint_ids).await + } + + async fn list_endpoints_by_provider_ids( + &self, + provider_ids: &[String], + ) -> Result, DataLayerError> { + self.check(FailedRead::Endpoints)?; + self.inner + .list_endpoints_by_provider_ids(provider_ids) + .await + } + + async fn list_keys_by_ids( + &self, + key_ids: &[String], + ) -> Result, DataLayerError> { + self.inner.list_keys_by_ids(key_ids).await + } + + async fn list_keys_by_provider_ids( + &self, + provider_ids: &[String], + ) -> Result, DataLayerError> { + self.inner.list_keys_by_provider_ids(provider_ids).await + } + + async fn list_key_summaries_by_provider_ids( + &self, + provider_ids: &[String], + ) -> Result, DataLayerError> { + self.check(FailedRead::Keys)?; + self.inner + .list_key_summaries_by_provider_ids(provider_ids) + .await + } + + async fn list_key_maintenance_summaries_by_provider_ids( + &self, + provider_ids: &[String], + ) -> Result, DataLayerError> { + self.inner + .list_key_maintenance_summaries_by_provider_ids(provider_ids) + .await + } + + async fn list_keys_page( + &self, + query: &ProviderCatalogKeyListQuery, + ) -> Result { + self.inner.list_keys_page(query).await + } + + async fn list_key_stats_by_provider_ids( + &self, + provider_ids: &[String], + ) -> Result, DataLayerError> { + self.inner + .list_key_stats_by_provider_ids(provider_ids) + .await + } +} + +#[tokio::test] +async fn admin_provider_summary_health_read_errors_do_not_look_like_empty_accounts() { + for failed_read in [ + FailedRead::Providers, + FailedRead::Endpoints, + FailedRead::Keys, + ] { + let repository = Arc::new(FailingSummaryRepository { + inner: InMemoryProviderCatalogReadRepository::seed( + vec![sample_provider("provider-openai", "openai", 10)], + vec![sample_endpoint( + "endpoint-chat", + "provider-openai", + "openai:chat", + "https://api.openai.example", + )], + vec![ + sample_key("key-observed", "provider-openai", "openai:chat", "test") + .with_health_fields( + Some(json!({"openai:chat": {"health_score": 1.0}})), + None, + ), + ], + ), + failed_read, + }); + let state = AppState::new() + .expect("gateway should build") + .with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests( + repository, + )); + + for uri in [ + "/api/admin/providers/summary", + "/api/admin/providers/summary?api_format=openai%3Achat", + "/api/admin/providers/provider-openai/summary", + ] { + let response = + local_admin_providers_response(&state, http::Method::GET, uri, None).await; + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "{failed_read:?}: {uri}" + ); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("error body should read"); + let payload: serde_json::Value = + serde_json::from_slice(&body).expect("error should parse"); + assert_eq!(payload["detail"], ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL); + assert!(payload.get("items").is_none()); + assert!(payload.get("endpoint_health_details").is_none()); + } + } +} + +#[tokio::test] +async fn admin_provider_summary_health_missing_provider_remains_not_found() { + let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![], + vec![], + vec![], + )); + let state = AppState::new() + .expect("gateway should build") + .with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests( + repository, + )); + + let response = local_admin_providers_response( + &state, + http::Method::GET, + "/api/admin/providers/provider-missing/summary", + None, + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn admin_provider_summary_health_counts_inherited_reverse_proxy_accounts() { + for (provider_type, auth_type, api_format) in [ + ("codex", "oauth", "openai:responses"), + ("kiro", "oauth", "claude:messages"), + ("kiro", "bearer", "claude:messages"), + ("gemini_cli", "oauth", "gemini:generate_content"), + ("antigravity", "oauth", "gemini:generate_content"), + ("vertex_ai", "service_account", "gemini:generate_content"), + ("chatgpt_web", "oauth", "openai:chat"), + ("chatgpt_web", "bearer", "openai:chat"), + ("windsurf", "oauth", "openai:chat"), + ] { + for configured_formats in [None, Some(json!([]))] { + for score in [None, Some(0.0), Some(0.75)] { + let mut provider = sample_provider("provider-reverse", provider_type, 10); + provider.provider_type = provider_type.to_string(); + let endpoint = sample_endpoint( + "endpoint-reverse", + &provider.id, + api_format, + "https://reverse.example", + ); + let mut key = sample_key("key-reverse", &provider.id, api_format, "test"); + key.auth_type = auth_type.to_string(); + key.api_formats = configured_formats.clone(); + key.encrypted_api_key = Some("summary".to_string()); + key.encrypted_auth_config = Some("{}".to_string()); + key.health_by_format = + score.map(|score| json!({api_format: {"health_score": score}})); + let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + vec![endpoint], + vec![key], + )); + let state = AppState::new() + .expect("gateway should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests(repository), + ); + + for uri in [ + "/api/admin/providers/summary", + "/api/admin/providers/provider-reverse/summary", + ] { + let response = + local_admin_providers_response(&state, http::Method::GET, uri, None).await; + assert_eq!( + response.status(), + StatusCode::OK, + "{provider_type}/{auth_type}: {uri}" + ); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("summary body should read"); + let payload: serde_json::Value = + serde_json::from_slice(&body).expect("summary should parse"); + let summary = payload + .get("items") + .map(|items| &items[0]) + .unwrap_or(&payload); + let detail = &summary["endpoint_health_details"][0]; + assert_eq!(detail["total_keys"], 1, "{provider_type}/{auth_type}"); + assert_eq!(detail["active_keys"], 1, "{provider_type}/{auth_type}"); + assert_eq!( + detail["health_score"], + json!(score.unwrap_or(1.0)), + "{provider_type}/{auth_type}" + ); + assert_eq!(summary["avg_health_score"], json!(score.unwrap_or(1.0))); + } + } + } + } +} diff --git a/docs/api/provider-health-summary.md b/docs/api/provider-health-summary.md index 9c104a624..30098c096 100644 --- a/docs/api/provider-health-summary.md +++ b/docs/api/provider-health-summary.md @@ -7,31 +7,47 @@ ## 统计规则 +沿用 `v0.7.13`(`535ee098c`)的默认健康规则:已启用密钥缺少该格式的健康记录时, +按 `1.0` 参与统计,而不是要求先有一次请求或探测才能显示健康。 + - 仅统计启用端点下、支持该端点 API 格式的启用密钥。 - 每个密钥读取其 `health_by_format[api_format].health_score`,不跨格式借用分数。 -- 只对已有的有效健康分数求算术平均;没有健康数据的密钥不按满分计入。 +- 对上述启用密钥求算术平均;缺少有效分数的密钥沿用旧版默认值 `1.0`。 - 分数范围为 `0` 到 `1`,沿用调度器的分数读取与范围约束。 -- 停用端点、没有启用密钥或没有有效健康分数时,端点的 `health_score` 返回 `null`。 -- `avg_health_score` 仅平均有有效健康数据的启用端点;没有此类端点时返回 `null`。 +- 停用端点或没有启用密钥时,端点的 `health_score` 返回 `null`。 +- `avg_health_score` 仅平均有启用密钥的启用端点,包含按默认值计算的端点;没有此类端点时返回 `null`。 - `unhealthy_endpoints` 仅统计上述端点中健康度低于 `0.5` 的数量,不把未知状态算作故障。 `total_keys` 和 `active_keys` 仍反映密钥配置数量,不因缺少健康数据而减少。 +Codex、Kiro、Gemini CLI、Antigravity 等固定提供商的账号按照各提供商的认证规则, +继承其启用端点的 API 格式;账号的 `api_formats` 为 `null` 或空数组,不代表没有配置账号。 +继承格式决定账号归属;缺少对应格式的分数时同样使用默认值 `1.0`,不借用其他格式的异常分数。 + ## 数据读取 摘要从密钥的轻量投影读取 API 格式、启用状态和 `health_by_format`。 PostgreSQL 投影中的凭据字段使用 `summary` / `{}` 等脱敏占位值,并非真实密文, 因此摘要读取不执行凭据解密、认证或迁移。完整密钥读取仍保留原有的凭据安全校验。 -若将这些占位值送入凭据校验,读取会失败,并被摘要聚合当作空密钥列表, -导致已配置密钥的端点也被错误显示为灰色;不能通过给缺失分数默认填 `100%` 来修复。 +若将这些占位值送入凭据校验,读取会失败,旧的摘要聚合还会将其当作空密钥列表, +导致已配置密钥的端点也被错误显示为灰色。默认健康分数只能用于成功读取的启用密钥, +不能用于掩盖查询或凭据投影错误。 + +提供商、端点或密钥摘要查询失败时,接口返回 `503`,不能将失败当作空列表并返回零账号。 +单个提供商确实不存在时仍返回 `404`。页面刷新失败保留已有列表,并显示加载错误。 ## 页面展示 -桌面表格和手机卡片使用相同规则: +桌面表格、网格卡片和手机卡片使用相同规则: - 有健康数据时显示百分比;有效的零分显示 `0%`。 -- 无健康数据时显示灰色占位条和 `-`,提示“暂无健康数据”,不显示 `100%` 或 `0%`。 +- 有启用密钥、但这些密钥尚无该格式的健康记录时,显示绿色 `100%`,与 `v0.7.13` 一致。 - 端点停用、未配置密钥或没有启用密钥时显示灰色占位条和对应状态提示。 -此分数是密钥当前健康状态的聚合,不是某个时间窗口内的请求成功率,也不是主动探测结果。 +账号详情保留原有默认 `100%` 的规则。已有观测仍取各格式最低分;端点分数则只聚合对应格式, +两者统计范围不同,不要求百分比完全相等。调度器原有的缺省健康策略不变。 + +此分数是密钥当前健康状态的聚合,包含默认健康值,不是某个时间窗口内的请求成功率, +也不表示已经执行过主动探测。相比 `v0.7.13`,仍保留停用账号/端点不参与健康聚合、 +凭据脱敏与查询失败显式报错等修复,不整体回退旧版代码。 diff --git a/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts b/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts index c22c53501..7e8e33f36 100644 --- a/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts +++ b/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts @@ -106,6 +106,29 @@ function mount(props: InstanceType['$props']) { } describe('ProviderKeyActionCluster', () => { + it.each([ + { score: 0, label: '0%' }, + { score: 1, label: '100%' }, + ])('renders the legacy health score $score as $label', ({ score, label }) => { + const { root, unmount } = mount({ + apiKey: createProviderKey({ health_score: score }), + providerType: 'codex', + recoverable: false, + recoverTitle: '', + circuitBreakerTitle: '', + circuitProbeCountdown: '', + healthScoreBarClass: 'bg-red-500', + healthScoreTextClass: 'text-red-600', + proxyPopoverOpen: false, + proxyNodeName: '', + }) + + const health = root.querySelector('[data-testid="provider-key-health"]') + expect(health?.textContent?.trim()).toBe(label) + expect(health?.textContent).not.toContain('待观测') + unmount() + }) + it('renders circuit, health, proxy and antigravity actions', () => { const { root, unmount } = mount({ apiKey: createProviderKey({ diff --git a/frontend/src/features/providers/components/__tests__/provider-endpoint-health.spec.ts b/frontend/src/features/providers/components/__tests__/provider-endpoint-health.spec.ts index 248301d57..b7b89b6e3 100644 --- a/frontend/src/features/providers/components/__tests__/provider-endpoint-health.spec.ts +++ b/frontend/src/features/providers/components/__tests__/provider-endpoint-health.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, type App, type Component } from 'vue' -import type { ProviderWithEndpointsSummary } from '@/api/endpoints' +import type { EndpointHealthDetail, ProviderWithEndpointsSummary } from '@/api/endpoints' import { createI18n } from '@/i18n' import ProviderTableRow from '../ProviderTableRow.vue' import ProviderMobileCard from '../ProviderMobileCard.vue' @@ -20,7 +20,7 @@ afterEach(() => { } }) -function mountProvider(component: Component, healthScore: number | null) { +function mountProvider(component: Component, healthScore: number | null, overrides: Partial = {}) { const provider: ProviderWithEndpointsSummary = { id: 'provider-1', name: 'Provider One', @@ -45,6 +45,7 @@ function mountProvider(component: Component, healthScore: number | null) { is_active: true, total_keys: 1, active_keys: 1, + ...overrides, }], ops_configured: false, created_at: '2026-09-06T00:00:00Z', @@ -81,6 +82,7 @@ describe.each([ { score: null, label: '-', width: '100%', color: 'bg-muted-foreground/40' }, { score: 0, label: '0%', width: '5%', color: 'bg-red-500' }, { score: 0.8, label: '80%', width: '80%', color: 'bg-green-500' }, + { score: 1, label: '100%', width: '100%', color: 'bg-green-500' }, ])('renders $score without confusing unknown health with zero', ({ score, label, width, color }) => { const root = mountProvider(component, score) const health = root.querySelector('[title*="健康"]') @@ -90,4 +92,17 @@ describe.each([ expect(bar?.style.width).toBe(width) expect(bar?.classList.contains(color)).toBe(true) }) + + it.each([ + { overrides: { is_active: false }, tooltip: '端点禁用' }, + { overrides: { active_keys: 0, total_keys: 0 }, tooltip: '未配置密钥' }, + { overrides: { active_keys: 0 }, tooltip: '无可用密钥' }, + ])('keeps $tooltip gray even when the health score defaults to one', ({ overrides, tooltip }) => { + const root = mountProvider(component, 1, overrides) + const health = root.querySelector(`[title*="${tooltip}"]`) + const bar = health?.querySelector('.transition-all') + + expect(health?.querySelectorAll('span')[1]?.textContent?.trim()).toBe('-') + expect(bar?.classList.contains('bg-muted-foreground/40')).toBe(true) + }) })