From d1b5eb08eef86e935fe39ba596aab91eb83cde34 Mon Sep 17 00:00:00 2001 From: elky Date: Sun, 6 Sep 2026 18:24:27 +0800 Subject: [PATCH] fix(providers): correct endpoint health aggregation and display --- .../handlers/admin/provider/summary/value.rs | 36 ++-- .../src/tests/control/admin/providers.rs | 192 ++++++++++++++++++ docs/api/provider-health-summary.md | 28 +++ frontend/src/api/endpoints/types/provider.ts | 4 +- .../components/ProviderMobileCard.vue | 12 +- .../providers/components/ProviderTableRow.vue | 12 +- .../provider-endpoint-health.spec.ts | 91 +++++++++ .../__tests__/useEndpointStatus.spec.ts | 59 +++++- .../composables/useEndpointStatus.ts | 29 ++- 9 files changed, 425 insertions(+), 38 deletions(-) create mode 100644 docs/api/provider-health-summary.md create mode 100644 frontend/src/features/providers/components/__tests__/provider-endpoint-health.spec.ts 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 27aad0d91..db68b61da 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/summary/value.rs @@ -13,6 +13,7 @@ use aether_data_contracts::repository::candidates::{ use aether_data_contracts::repository::provider_catalog::{ StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, }; +use aether_scheduler_core::provider_key_health_score; use serde_json::json; use std::collections::BTreeMap; @@ -91,23 +92,17 @@ pub(crate) fn build_admin_provider_summary_value( .get(&endpoint.id) .cloned() .unwrap_or_default(); - let health_score = if endpoint_keys.is_empty() { - 1.0 - } else { - let mut scores = Vec::new(); - for key in &endpoint_keys { - let score = key - .health_by_format - .as_ref() - .and_then(|value| value.get(&endpoint.api_format)) - .and_then(|value| value.get("health_score")) - .and_then(serde_json::Value::as_f64) - .unwrap_or(1.0); - scores.push(score); - } - scores.iter().sum::() / scores.len() as f64 - }; - endpoint_health_scores.push(health_score); + 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()) + .collect::>(); + let health_score = + (!scores.is_empty()).then(|| scores.iter().sum::() / scores.len() as f64); + if let Some(score) = health_score { + endpoint_health_scores.push(score); + } json!({ "api_format": endpoint.api_format, "health_score": health_score, @@ -117,11 +112,8 @@ pub(crate) fn build_admin_provider_summary_value( }) }) .collect::>(); - let avg_health_score = if endpoint_health_scores.is_empty() { - 1.0 - } else { - endpoint_health_scores.iter().sum::() / endpoint_health_scores.len() as f64 - }; + let avg_health_score = (!endpoint_health_scores.is_empty()) + .then(|| endpoint_health_scores.iter().sum::() / endpoint_health_scores.len() as f64); let unhealthy_endpoints = endpoint_health_scores .iter() .filter(|score| **score < 0.5) diff --git a/apps/aether-gateway/src/tests/control/admin/providers.rs b/apps/aether-gateway/src/tests/control/admin/providers.rs index 2ad7f5fd7..c63ed351d 100644 --- a/apps/aether-gateway/src/tests/control/admin/providers.rs +++ b/apps/aether-gateway/src/tests/control/admin/providers.rs @@ -40,6 +40,198 @@ use crate::data::GatewayDataState; const ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin provider catalog data unavailable"; +async fn provider_health_summary( + endpoints: &[StoredProviderCatalogEndpoint], + keys: &[StoredProviderCatalogKey], +) -> serde_json::Value { + let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![sample_provider("provider-openai", "openai", 10)], + endpoints.to_vec(), + keys.to_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/summary", + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + 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"); + assert_eq!(payload["items"].as_array().map(Vec::len), Some(1)); + payload["items"][0].clone() +} + +#[tokio::test] +async fn admin_provider_summary_health_ignores_disabled_keys() { + let endpoint = sample_endpoint( + "endpoint-chat", + "provider-openai", + "openai:chat", + "https://api.openai.example", + ); + for (active_score, disabled_score) in [(0.2, 1.0), (1.0, 0.2), (0.0, 1.0)] { + let active_key = sample_key("key-active", "provider-openai", "openai:chat", "test") + .with_health_fields( + Some(json!({"openai:chat": {"health_score": active_score}})), + None, + ); + let mut disabled_key = sample_key("key-disabled", "provider-openai", "openai:chat", "test") + .with_health_fields( + Some(json!({"openai:chat": {"health_score": disabled_score}})), + None, + ); + disabled_key.is_active = false; + + let payload = + provider_health_summary(std::slice::from_ref(&endpoint), &[active_key, disabled_key]) + .await; + + assert_eq!( + payload["endpoint_health_details"][0]["health_score"], + active_score + ); + assert_eq!(payload["endpoint_health_details"][0]["total_keys"], 2); + assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 1); + assert_eq!(payload["avg_health_score"], active_score); + assert_eq!( + payload["unhealthy_endpoints"], + usize::from(active_score < 0.5) + ); + } +} + +#[tokio::test] +async fn admin_provider_summary_health_does_not_inflate_observed_scores_with_missing_data() { + let endpoint = sample_endpoint( + "endpoint-chat", + "provider-openai", + "openai:chat", + "https://api.openai.example", + ); + let keys = [ + sample_key("key-observed", "provider-openai", "openai:chat", "test") + .with_health_fields(Some(json!({"openai:chat": {"health_score": 0.2}})), None), + 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}})), + None, + ), + ]; + + let payload = provider_health_summary(&[endpoint], &keys).await; + + assert_eq!(payload["endpoint_health_details"][0]["health_score"], 0.2); + assert_eq!(payload["endpoint_health_details"][0]["active_keys"], 3); + assert_eq!(payload["avg_health_score"], 0.2); + assert_eq!(payload["unhealthy_endpoints"], 1); +} + +#[tokio::test] +async fn admin_provider_summary_health_is_unknown_without_active_observations() { + let endpoint = sample_endpoint( + "endpoint-chat", + "provider-openai", + "openai:chat", + "https://api.openai.example", + ); + 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], + ] { + let payload = provider_health_summary(std::slice::from_ref(&endpoint), &keys).await; + + assert_eq!( + payload["endpoint_health_details"][0]["health_score"], + json!(null) + ); + assert_eq!(payload["avg_health_score"], json!(null)); + assert_eq!(payload["unhealthy_endpoints"], 0); + } + + let payload = provider_health_summary(&[], &[]).await; + assert_eq!(payload["avg_health_score"], json!(null)); + assert_eq!(payload["unhealthy_endpoints"], 0); +} + +#[tokio::test] +async fn admin_provider_summary_health_excludes_disabled_and_unobserved_endpoints() { + let mut disabled_endpoint = sample_endpoint( + "endpoint-disabled", + "provider-openai", + "openai:responses", + "https://api.openai.example", + ); + disabled_endpoint.is_active = false; + let endpoints = [ + sample_endpoint( + "endpoint-chat", + "provider-openai", + "openai:chat", + "https://api.openai.example", + ), + disabled_endpoint, + sample_endpoint( + "endpoint-unobserved", + "provider-openai", + "openai:embedding", + "https://api.openai.example", + ), + ]; + let keys = [ + sample_key("key-chat", "provider-openai", "openai:chat", "test") + .with_health_fields(Some(json!({"openai:chat": {"health_score": 0.8}})), None), + sample_key( + "key-responses", + "provider-openai", + "openai:responses", + "test", + ) + .with_health_fields( + Some(json!({"openai:responses": {"health_score": 0.2}})), + None, + ), + sample_key( + "key-unobserved", + "provider-openai", + "openai:embedding", + "test", + ), + ]; + + 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); + assert_eq!(payload["unhealthy_endpoints"], 0); +} + fn trusted_admin_headers() -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(GATEWAY_HEADER, HeaderValue::from_static("rust-phase3b")); diff --git a/docs/api/provider-health-summary.md b/docs/api/provider-health-summary.md new file mode 100644 index 000000000..1c103c099 --- /dev/null +++ b/docs/api/provider-health-summary.md @@ -0,0 +1,28 @@ +# 提供商管理的端点健康度 + +以下管理接口使用相同的健康度汇总规则: + +- `GET /api/admin/providers/summary` +- `GET /api/admin/providers/{provider_id}/summary` + +## 统计规则 + +- 仅统计启用端点下、支持该端点 API 格式的启用密钥。 +- 每个密钥读取其 `health_by_format[api_format].health_score`,不跨格式借用分数。 +- 只对已有的有效健康分数求算术平均;没有健康数据的密钥不按满分计入。 +- 分数范围为 `0` 到 `1`,沿用调度器的分数读取与范围约束。 +- 停用端点、没有启用密钥或没有有效健康分数时,端点的 `health_score` 返回 `null`。 +- `avg_health_score` 仅平均有有效健康数据的启用端点;没有此类端点时返回 `null`。 +- `unhealthy_endpoints` 仅统计上述端点中健康度低于 `0.5` 的数量,不把未知状态算作故障。 + +`total_keys` 和 `active_keys` 仍反映密钥配置数量,不因缺少健康数据而减少。 + +## 页面展示 + +桌面表格和手机卡片使用相同规则: + +- 有健康数据时显示百分比;有效的零分显示 `0%`。 +- 无健康数据时显示灰色占位条和 `-`,提示“暂无健康数据”,不显示 `100%` 或 `0%`。 +- 端点停用、未配置密钥或没有启用密钥时显示灰色占位条和对应状态提示。 + +此分数是密钥当前健康状态的聚合,不是某个时间窗口内的请求成功率,也不是主动探测结果。 diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index 0fc409c2a..0d55c80c1 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -581,7 +581,7 @@ export interface EndpointAPIKeyUpdate { export interface EndpointHealthDetail { api_format: string - health_score: number + health_score: number | null is_active: boolean total_keys?: number active_keys?: number @@ -914,7 +914,7 @@ export interface ProviderWithEndpointsSummary { total_models: number active_models: number global_model_ids: string[] - avg_health_score: number + avg_health_score: number | null unhealthy_endpoints: number api_formats: string[] endpoint_health_details: EndpointHealthDetail[] diff --git a/frontend/src/features/providers/components/ProviderMobileCard.vue b/frontend/src/features/providers/components/ProviderMobileCard.vue index b624f8e0c..c1a4c5791 100644 --- a/frontend/src/features/providers/components/ProviderMobileCard.vue +++ b/frontend/src/features/providers/components/ProviderMobileCard.vue @@ -203,7 +203,7 @@ {{ formatApiFormatShort(endpoint.api_format) }} - {{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }} + {{ getEndpointHealthLabel(endpoint) }} @@ -212,7 +212,7 @@
@@ -238,7 +238,13 @@ import Button from '@/components/ui/button.vue' import Badge from '@/components/ui/badge.vue' import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints' import { formatBillingType } from '@/utils/format' -import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus' +import { + sortEndpoints, + getEndpointHealthLabel, + getEndpointHealthBarWidth, + getEndpointDotColor, + getEndpointTooltip, +} from '@/features/providers/composables/useEndpointStatus' import { isKeyManagedProviderType } from '../utils/providerTypeUtils' import { useI18n } from '@/i18n' import { safeExternalWebUrl } from '@/utils/navigationSecurity' diff --git a/frontend/src/features/providers/components/ProviderTableRow.vue b/frontend/src/features/providers/components/ProviderTableRow.vue index 5326632b7..470149845 100644 --- a/frontend/src/features/providers/components/ProviderTableRow.vue +++ b/frontend/src/features/providers/components/ProviderTableRow.vue @@ -109,7 +109,7 @@ {{ formatApiFormatShort(endpoint.api_format) }} - {{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }} + {{ getEndpointHealthLabel(endpoint) }} @@ -118,7 +118,7 @@
@@ -210,7 +210,13 @@ import TableRow from '@/components/ui/table-row.vue' import TableCell from '@/components/ui/table-cell.vue' import ProviderBalanceCell from './ProviderBalanceCell.vue' import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints' -import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus' +import { + sortEndpoints, + getEndpointHealthLabel, + getEndpointHealthBarWidth, + getEndpointDotColor, + getEndpointTooltip, +} from '@/features/providers/composables/useEndpointStatus' import type { BalanceExtraItem } from '@/features/providers/auth-templates' import { useI18n } from '@/i18n' import { safeExternalWebUrl } from '@/utils/navigationSecurity' 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 new file mode 100644 index 000000000..a7700e4b6 --- /dev/null +++ b/frontend/src/features/providers/components/__tests__/provider-endpoint-health.spec.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApp, type App, type Component } from 'vue' + +import type { ProviderWithEndpointsSummary } from '@/api/endpoints' +import { createI18n } from '@/i18n' +import ProviderTableRow from '../ProviderTableRow.vue' +import ProviderMobileCard from '../ProviderMobileCard.vue' + +vi.mock('../ProviderBalanceCell.vue', () => ({ + default: { render: () => null }, +})) + +const mountedApps: Array<{ app: App, root: HTMLElement }> = [] + +afterEach(() => { + for (const { app, root } of mountedApps.splice(0)) { + app.unmount() + root.remove() + } +}) + +function mountProvider(component: Component, healthScore: number | null) { + const provider: ProviderWithEndpointsSummary = { + id: 'provider-1', + name: 'Provider One', + provider_type: 'custom', + provider_priority: 100, + keep_priority_on_conversion: false, + enable_format_conversion: true, + is_active: true, + total_endpoints: 1, + active_endpoints: 1, + total_keys: 1, + active_keys: 1, + total_models: 0, + active_models: 0, + global_model_ids: [], + avg_health_score: healthScore, + unhealthy_endpoints: healthScore === 0 ? 1 : 0, + api_formats: ['openai:chat'], + endpoint_health_details: [{ + api_format: 'openai:chat', + health_score: healthScore, + is_active: true, + total_keys: 1, + active_keys: 1, + }], + ops_configured: false, + created_at: '2026-09-06T00:00:00Z', + updated_at: '2026-09-06T00:00:00Z', + } + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(component, { + provider, + editingDescriptionId: null, + isBalanceLoading: () => false, + getProviderBalance: () => null, + getProviderBalanceBreakdown: () => null, + getProviderBalanceError: () => null, + getProviderCheckin: () => null, + getProviderCookieExpired: () => null, + getProviderBalanceExtra: () => [], + formatBalanceDisplay: () => '-', + formatResetCountdown: () => '', + getQuotaUsedColorClass: () => '', + }) + app.use(createI18n()) + app.mount(root) + mountedApps.push({ app, root }) + return root +} + +describe.each([ + ['desktop provider row', ProviderTableRow], + ['mobile provider card', ProviderMobileCard], +] as const)('%s endpoint health', (_name, component) => { + it.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' }, + ])('renders $score without confusing unknown health with zero', ({ score, label, width, color }) => { + const root = mountProvider(component, score) + const health = root.querySelector('[title*="健康"]') + const bar = health?.querySelector('.transition-all') + + expect(health?.querySelectorAll('span')[1]?.textContent?.trim()).toBe(label) + expect(bar?.style.width).toBe(width) + expect(bar?.classList.contains(color)).toBe(true) + }) +}) diff --git a/frontend/src/features/providers/composables/__tests__/useEndpointStatus.spec.ts b/frontend/src/features/providers/composables/__tests__/useEndpointStatus.spec.ts index 03f1ae8c4..d52dfbe63 100644 --- a/frontend/src/features/providers/composables/__tests__/useEndpointStatus.spec.ts +++ b/frontend/src/features/providers/composables/__tests__/useEndpointStatus.spec.ts @@ -1,6 +1,63 @@ import { describe, expect, it } from 'vitest' -import { getEndpointTooltip } from '../useEndpointStatus' +import type { EndpointHealthDetail } from '@/api/endpoints' +import { + getEndpointDotColor, + getEndpointHealthLabel, + getEndpointHealthBarWidth, + getEndpointTooltip, +} from '../useEndpointStatus' + +const availableEndpoint: EndpointHealthDetail = { + api_format: 'openai:chat', + health_score: 1, + is_active: true, + total_keys: 1, + active_keys: 1, +} + +describe('endpoint health display', () => { + it.each([null, undefined, Number.NaN, Number.POSITIVE_INFINITY])( + 'renders missing or invalid health %s as unknown rather than a percentage', + (healthScore) => { + const endpoint = { ...availableEndpoint, health_score: healthScore } as EndpointHealthDetail + + expect(getEndpointHealthLabel(endpoint)).toBe('-') + expect(getEndpointHealthBarWidth(endpoint)).toBe('100%') + expect(getEndpointDotColor(endpoint)).toBe('bg-muted-foreground/40') + expect(getEndpointTooltip(endpoint, 'zh-CN')).toContain('暂无健康数据') + }, + ) + + it.each([ + { is_active: false }, + { active_keys: 0 }, + { active_keys: 0, total_keys: 0 }, + ])('does not show a health percentage for unavailable endpoint %s', (overrides) => { + const endpoint = { ...availableEndpoint, ...overrides } + + expect(getEndpointHealthLabel(endpoint)).toBe('-') + expect(getEndpointHealthBarWidth(endpoint)).toBe('100%') + expect(getEndpointDotColor(endpoint)).toBe('bg-muted-foreground/40') + }) + + it.each([ + [0, '0%', '5%', 'bg-red-500'], + [0.2, '20%', '20%', 'bg-red-500'], + [0.5, '50%', '50%', 'bg-amber-500'], + [0.8, '80%', '80%', 'bg-green-500'], + [1, '100%', '100%', 'bg-green-500'], + [-0.2, '0%', '5%', 'bg-red-500'], + [1.2, '100%', '100%', 'bg-green-500'], + ] as const)('renders score %s consistently across label, bar and tooltip', (score, label, width, color) => { + const endpoint = { ...availableEndpoint, health_score: score } + + expect(getEndpointHealthLabel(endpoint)).toBe(label) + expect(getEndpointHealthBarWidth(endpoint)).toBe(width) + expect(getEndpointDotColor(endpoint)).toBe(color) + expect(getEndpointTooltip(endpoint, 'zh-CN')).toContain(`健康度 ${label}`) + }) +}) describe('getEndpointTooltip', () => { it('formats the internal Codex Live id at the user-visible tooltip boundary', () => { diff --git a/frontend/src/features/providers/composables/useEndpointStatus.ts b/frontend/src/features/providers/composables/useEndpointStatus.ts index 99caffcfb..a1cce5c70 100644 --- a/frontend/src/features/providers/composables/useEndpointStatus.ts +++ b/frontend/src/features/providers/composables/useEndpointStatus.ts @@ -32,11 +32,29 @@ export function isEndpointAvailable(endpoint: EndpointHealthDetail): boolean { return getEndpointStatus(endpoint) === 'available' } +function getEndpointHealthScore(endpoint: EndpointHealthDetail): number | null { + const score = endpoint.health_score + if (!isEndpointAvailable(endpoint) || typeof score !== 'number' || !Number.isFinite(score)) { + return null + } + return Math.min(1, Math.max(0, score)) +} + +export function getEndpointHealthLabel(endpoint: EndpointHealthDetail): string { + const score = getEndpointHealthScore(endpoint) + return score === null ? '-' : `${(score * 100).toFixed(0)}%` +} + +export function getEndpointHealthBarWidth(endpoint: EndpointHealthDetail): string { + const score = getEndpointHealthScore(endpoint) + return score === null ? '100%' : `${Math.max(score * 100, 5)}%` +} + /** * 根据健康分数获取颜色 */ export function getHealthScoreColor(score: number | undefined | null): string { - if (score === undefined || score === null) { + if (score === undefined || score === null || !Number.isFinite(score)) { return 'bg-muted-foreground/40' } if (score >= 0.8) return 'bg-green-500' @@ -48,10 +66,7 @@ export function getHealthScoreColor(score: number | undefined | null): string { * 端点不可用时进度条颜色 */ export function getEndpointDotColor(endpoint: EndpointHealthDetail): string { - if (!isEndpointAvailable(endpoint)) { - return 'bg-muted-foreground/40' - } - return getHealthScoreColor(endpoint.health_score) + return getHealthScoreColor(getEndpointHealthScore(endpoint)) } /** @@ -70,8 +85,8 @@ export function getEndpointTooltip(endpoint: EndpointHealthDetail, locale: Local case 'keys_disabled': return `${format}: ${t('无可用密钥')}` case 'available': { - const score = endpoint.health_score - if (score === undefined || score === null) { + const score = getEndpointHealthScore(endpoint) + if (score === null) { return `${format}: ${t('暂无健康数据')}` } return `${format}: ${t('健康度')} ${(score * 100).toFixed(0)}%`