diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs index 8b35cb0e4..970761fad 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs @@ -67,6 +67,7 @@ fn parse_users_me_usage_offset(query: Option<&str>) -> Result { #[derive(Clone, Debug, Default, PartialEq, Eq)] struct UsersMeUsageRecordFilter { + api_format: Option, statuses: Option>, is_stream: Option, is_websocket: Option, @@ -74,14 +75,19 @@ struct UsersMeUsageRecordFilter { } fn parse_users_me_usage_record_filter(query: Option<&str>) -> UsersMeUsageRecordFilter { + let mut filter = UsersMeUsageRecordFilter { + api_format: query_param_value(query, "api_format") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + ..UsersMeUsageRecordFilter::default() + }; let Some(status) = query_param_value(query, "status") .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) else { - return UsersMeUsageRecordFilter::default(); + return filter; }; - let mut filter = UsersMeUsageRecordFilter::default(); match status.as_str() { "stream" => { filter.is_stream = Some(true); @@ -1134,7 +1140,7 @@ pub(super) async fn handle_users_me_usage_get( user_id: Some(auth.user.id.clone()), provider_name: None, model: None, - api_format: None, + api_format: record_filter.api_format.clone(), client_family: None, exclude_unknown_model_or_provider: false, statuses: record_filter.statuses.clone(), @@ -1191,7 +1197,7 @@ pub(super) async fn handle_users_me_usage_get( user_id: Some(auth.user.id.clone()), provider_name: None, model: None, - api_format: None, + api_format: record_filter.api_format.clone(), client_family: None, exclude_unknown_model_or_provider: false, statuses: record_filter.statuses.clone(), @@ -1221,7 +1227,7 @@ pub(super) async fn handle_users_me_usage_get( user_id: Some(auth.user.id.clone()), provider_name: None, model: None, - api_format: None, + api_format: record_filter.api_format.clone(), client_family: None, exclude_unknown_model_or_provider: false, statuses: record_filter.statuses.clone(), @@ -1629,6 +1635,20 @@ mod tests { #[test] fn users_me_usage_transport_statuses_are_disjoint_server_side_filters() { + let live_websocket = parse_users_me_usage_record_filter(Some( + "limit=20&api_format=codex%3Alive&status=websocket", + )); + assert_eq!(live_websocket.api_format.as_deref(), Some("codex:live")); + assert_eq!(live_websocket.is_websocket, Some(true)); + + let live_without_status = + parse_users_me_usage_record_filter(Some("api_format=codex%3Alive")); + assert_eq!( + live_without_status.api_format.as_deref(), + Some("codex:live") + ); + assert_eq!(live_without_status.is_websocket, None); + for status in ["websocket", "ws", "WS"] { let filter = parse_users_me_usage_record_filter(Some( format!("limit=20&status={status}").as_str(), diff --git a/apps/aether-gateway/src/tests/control/admin/usage.rs b/apps/aether-gateway/src/tests/control/admin/usage.rs index 67bd9802e..7429df7f4 100644 --- a/apps/aether-gateway/src/tests/control/admin/usage.rs +++ b/apps/aether-gateway/src/tests/control/admin/usage.rs @@ -13,7 +13,7 @@ use aether_data_contracts::repository::candidates::{ RequestCandidateStatus, StoredRequestCandidate, }; use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageBodyCaptureState}; -use axum::body::{Body, Bytes}; +use axum::body::{to_bytes, Body, Bytes}; use axum::routing::{any, get, post}; use axum::{extract::Request, Router}; use http::{HeaderMap, HeaderValue, StatusCode}; @@ -2173,6 +2173,82 @@ async fn gateway_handles_admin_usage_detail_locally_with_trusted_admin_principal upstream_handle.abort(); } +#[tokio::test] +async fn gateway_admin_usage_detail_preserves_live_websocket_session_metadata() { + let live_session = json!({ + "schema_version": "1", + "transport": "websocket", + "mode": "direct", + "state": "closed", + "client_frames": 3, + "upstream_frames": 5, + }); + let realtime_session = json!({ + "schema_version": "1", + "transport": "websocket", + "usage_state": "authoritative", + "input_audio_tokens": 11, + "output_audio_tokens": 6, + }); + let mut usage = sample_usage_row( + "usage-live-detail", + "req-live-detail", + None, + None, + None, + "OpenAI", + "gpt-live", + "completed", + 0, + 0, + 0.0, + 0.0, + DAY_1_UNIX_SECS, + ); + usage.request_type = Some("live".to_string()); + usage.api_format = Some("codex:live".to_string()); + usage.api_family = Some("codex".to_string()); + usage.endpoint_kind = Some("live".to_string()); + usage.endpoint_api_format = Some("codex:live".to_string()); + usage.provider_api_family = Some("codex".to_string()); + usage.provider_endpoint_kind = Some("live".to_string()); + usage.is_stream = true; + usage.request_metadata = Some(json!({ + "websocket_mode": true, + "websocket_transport": "codex_live_direct", + "usage_available": false, + "usage_pricing_available": false, + "live_session": live_session, + "realtime_session": realtime_session, + })); + + let state = AppState::new() + .expect("gateway should build") + .with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(Arc::new( + InMemoryUsageReadRepository::seed(vec![usage]), + ))); + let response = local_admin_usage_response( + &state, + http::Method::GET, + "/api/admin/usage/usage-live-detail?include_bodies=false", + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("usage detail body should be readable"); + let payload: serde_json::Value = + serde_json::from_slice(&body).expect("usage detail body should be JSON"); + assert_eq!(payload["is_websocket"], true); + assert_eq!(payload["websocket_transport"], "codex_live_direct"); + assert_eq!(payload["live_session"], live_session); + assert_eq!(payload["realtime_session"], realtime_session); + assert_eq!(payload["metadata"]["live_session"], live_session); + assert_eq!(payload["metadata"]["realtime_session"], realtime_session); +} + #[tokio::test] async fn gateway_handles_admin_usage_detail_with_ref_backed_bodies() { let (_upstream_url, upstream_hits, upstream_handle) = diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs index 5c4023d31..7215a08b4 100644 --- a/crates/aether-admin/src/observability/usage.rs +++ b/crates/aether-admin/src/observability/usage.rs @@ -7,7 +7,10 @@ use aether_billing::{ use aether_data::repository::users::StoredUserSummary; use aether_data_contracts::repository::{ provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider}, - usage::{StoredRequestUsageAudit, StoredUsageAuditSummary, UsageBodyField}, + usage::{ + StoredRequestUsageAudit, StoredUsageAuditSummary, UsageBodyField, + LIVE_SESSION_METADATA_KEY, REALTIME_SESSION_METADATA_KEY, + }, }; use axum::{ body::Body, @@ -2472,6 +2475,16 @@ pub fn build_admin_usage_detail_payload( admin_usage_strip_settlement_metadata(object); admin_usage_strip_trace_metadata(object); } + let live_session = metadata + .as_object() + .and_then(|object| object.get(LIVE_SESSION_METADATA_KEY)) + .cloned() + .unwrap_or(Value::Null); + let realtime_session = metadata + .as_object() + .and_then(|object| object.get(REALTIME_SESSION_METADATA_KEY)) + .cloned() + .unwrap_or(Value::Null); payload["user"] = match item.user_id.as_ref() { Some(user_id) => json!({ "id": user_id, @@ -2502,6 +2515,11 @@ pub fn build_admin_usage_detail_payload( payload["client_response_headers"] = item.client_response_headers.clone().unwrap_or(Value::Null); payload["metadata"] = metadata; + // Session summaries are also first-class detail fields. Keep the original + // values in `metadata` for backward compatibility while making the detail + // contract independent from the generic metadata viewer. + payload[LIVE_SESSION_METADATA_KEY] = live_session; + payload[REALTIME_SESSION_METADATA_KEY] = realtime_session; payload["routing"] = admin_usage_routing_json(item, provider_key_name); payload["body_capture"] = admin_usage_body_capture_json(item); payload["settlement"] = admin_usage_settlement_json(item); @@ -2771,6 +2789,59 @@ mod tests { assert!(!admin_usage_matches_status(&item, Some("stream"))); } + #[test] + fn admin_usage_detail_preserves_websocket_and_session_metadata() { + let live_session = json!({ + "schema_version": "1", + "transport": "websocket", + "mode": "direct", + "state": "closed", + "client_frames": 4, + "upstream_frames": 8, + }); + let realtime_session = json!({ + "schema_version": "1", + "transport": "websocket", + "usage_state": "authoritative", + "input_audio_tokens": 7, + "output_audio_tokens": 3, + }); + let item = StoredRequestUsageAudit { + request_type: Some("live".to_string()), + api_format: Some("codex:live".to_string()), + endpoint_api_format: Some("codex:live".to_string()), + is_stream: true, + request_metadata: Some(json!({ + "websocket_mode": true, + "websocket_transport": "codex_live_direct", + "usage_available": false, + "usage_pricing_available": false, + "live_session": live_session, + "realtime_session": realtime_session, + })), + ..sample_usage("completed", Some(200), None) + }; + + let payload = build_admin_usage_detail_payload( + &item, + &BTreeMap::new(), + &BTreeMap::new(), + false, + false, + None, + false, + None, + &BTreeMap::new(), + ); + + assert_eq!(payload["is_websocket"], true); + assert_eq!(payload["websocket_transport"], "codex_live_direct"); + assert_eq!(payload["live_session"], live_session); + assert_eq!(payload["realtime_session"], realtime_session); + assert_eq!(payload["metadata"]["live_session"], live_session); + assert_eq!(payload["metadata"]["realtime_session"], realtime_session); + } + #[test] fn admin_usage_record_infers_client_family_from_user_agent() { let item = StoredRequestUsageAudit { diff --git a/crates/aether-admin/src/system.rs b/crates/aether-admin/src/system.rs index ddd653dff..83a46de99 100644 --- a/crates/aether-admin/src/system.rs +++ b/crates/aether-admin/src/system.rs @@ -807,7 +807,7 @@ const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[ }, AdminApiFormatDefinition { value: "codex:live", - label: "Codex Live", + label: "OpenAI Live", default_path: "/v1/live", aliases: &["codex_live", "live"], }, @@ -3146,7 +3146,7 @@ mod tests { let live = formats .iter() .find(|format| format["value"] == "codex:live") - .expect("Codex Live format should be registered"); + .expect("OpenAI Live format should be registered"); assert_eq!(realtime["label"], "OpenAI Realtime"); assert_eq!(realtime["default_path"], "/v1/realtime"); @@ -3154,7 +3154,7 @@ mod tests { realtime["aliases"], serde_json::json!(["openai_realtime", "realtime"]) ); - assert_eq!(live["label"], "Codex Live"); + assert_eq!(live["label"], "OpenAI Live"); assert_eq!(live["default_path"], "/v1/live"); assert_eq!(live["aliases"], serde_json::json!(["codex_live", "live"])); } diff --git a/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql b/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql index 82da7e825..f4515ac2d 100644 --- a/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql +++ b/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql @@ -186,6 +186,11 @@ SELECT OR ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false') OR ("usage".request_metadata->>'websocket_mode') IN ('true', 'false') + OR NULLIF(BTRIM("usage".request_metadata->>'websocket_transport'), '') IS NOT NULL + OR ("usage".request_metadata->>'usage_available') IN ('true', 'false') + OR ("usage".request_metadata->>'usage_pricing_available') IN ('true', 'false') + OR json_typeof("usage".request_metadata->'live_session') = 'object' + OR json_typeof("usage".request_metadata->'realtime_session') = 'object' THEN jsonb_strip_nulls(jsonb_build_object( 'client_ip', NULLIF(BTRIM("usage".request_metadata->>'client_ip'), ''), @@ -220,6 +225,32 @@ SELECT WHEN ("usage".request_metadata->>'websocket_mode') IN ('true', 'false') THEN ("usage".request_metadata->>'websocket_mode')::boolean ELSE NULL + END, + 'websocket_transport', + NULLIF(BTRIM("usage".request_metadata->>'websocket_transport'), ''), + 'usage_available', + CASE + WHEN ("usage".request_metadata->>'usage_available') IN ('true', 'false') + THEN ("usage".request_metadata->>'usage_available')::boolean + ELSE NULL + END, + 'usage_pricing_available', + CASE + WHEN ("usage".request_metadata->>'usage_pricing_available') IN ('true', 'false') + THEN ("usage".request_metadata->>'usage_pricing_available')::boolean + ELSE NULL + END, + 'live_session', + CASE + WHEN json_typeof("usage".request_metadata->'live_session') = 'object' + THEN "usage".request_metadata->'live_session' + ELSE NULL + END, + 'realtime_session', + CASE + WHEN json_typeof("usage".request_metadata->'realtime_session') = 'object' + THEN "usage".request_metadata->'realtime_session' + ELSE NULL END ))::json ELSE NULL::json diff --git a/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql b/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql index 82da7e825..f4515ac2d 100644 --- a/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql +++ b/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql @@ -186,6 +186,11 @@ SELECT OR ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false') OR ("usage".request_metadata->>'websocket_mode') IN ('true', 'false') + OR NULLIF(BTRIM("usage".request_metadata->>'websocket_transport'), '') IS NOT NULL + OR ("usage".request_metadata->>'usage_available') IN ('true', 'false') + OR ("usage".request_metadata->>'usage_pricing_available') IN ('true', 'false') + OR json_typeof("usage".request_metadata->'live_session') = 'object' + OR json_typeof("usage".request_metadata->'realtime_session') = 'object' THEN jsonb_strip_nulls(jsonb_build_object( 'client_ip', NULLIF(BTRIM("usage".request_metadata->>'client_ip'), ''), @@ -220,6 +225,32 @@ SELECT WHEN ("usage".request_metadata->>'websocket_mode') IN ('true', 'false') THEN ("usage".request_metadata->>'websocket_mode')::boolean ELSE NULL + END, + 'websocket_transport', + NULLIF(BTRIM("usage".request_metadata->>'websocket_transport'), ''), + 'usage_available', + CASE + WHEN ("usage".request_metadata->>'usage_available') IN ('true', 'false') + THEN ("usage".request_metadata->>'usage_available')::boolean + ELSE NULL + END, + 'usage_pricing_available', + CASE + WHEN ("usage".request_metadata->>'usage_pricing_available') IN ('true', 'false') + THEN ("usage".request_metadata->>'usage_pricing_available')::boolean + ELSE NULL + END, + 'live_session', + CASE + WHEN json_typeof("usage".request_metadata->'live_session') = 'object' + THEN "usage".request_metadata->'live_session' + ELSE NULL + END, + 'realtime_session', + CASE + WHEN json_typeof("usage".request_metadata->'realtime_session') = 'object' + THEN "usage".request_metadata->'realtime_session' + ELSE NULL END ))::json ELSE NULL::json diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index 22d14b690..a6fab483d 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -3109,6 +3109,21 @@ fn usage_sql_reads_http_audits_for_single_record_fetches() { assert!(super::FIND_BY_ID_SQL.contains("http_client_response_body_ref")); } +#[test] +fn usage_sql_single_record_fetches_preserve_full_request_metadata() { + for sql in [super::FIND_BY_REQUEST_ID_SQL, super::FIND_BY_ID_SQL] { + assert!( + sql.contains("\n \"usage\".request_metadata,\n"), + "single-record usage detail queries must preserve the full request metadata, \ + including WebSocket and Live session summaries" + ); + assert!( + !sql.contains("END AS request_metadata"), + "bounded list metadata projection must not leak into detail queries" + ); + } +} + #[test] fn usage_sql_reads_routing_snapshots_for_single_record_fetches() { assert!(super::FIND_BY_REQUEST_ID_SQL.contains("LEFT JOIN usage_routing_snapshots")); @@ -3240,6 +3255,42 @@ fn usage_sql_uses_json_null_placeholders_for_usage_payload_columns() { assert!(!super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("NULL::jsonb")); } +#[test] +fn usage_sql_list_queries_project_bounded_live_and_realtime_metadata() { + for sql in [ + super::LIST_USAGE_AUDITS_PREFIX, + super::LIST_RECENT_USAGE_AUDITS_PREFIX, + ] { + assert!(sql.contains("'websocket_transport'")); + assert!( + sql.contains("NULLIF(BTRIM(\"usage\".request_metadata->>'websocket_transport'), '')") + ); + + for key in ["usage_available", "usage_pricing_available"] { + assert!(sql.contains(format!("'{key}'").as_str())); + assert!(sql.contains( + format!("WHEN (\"usage\".request_metadata->>'{key}') IN ('true', 'false')") + .as_str() + )); + assert!(sql.contains( + format!("THEN (\"usage\".request_metadata->>'{key}')::boolean").as_str() + )); + } + + for key in ["live_session", "realtime_session"] { + assert!(sql.contains(format!("'{key}'").as_str())); + assert!(sql.contains( + format!("WHEN json_typeof(\"usage\".request_metadata->'{key}') = 'object'") + .as_str() + )); + assert!(sql.contains(format!("THEN \"usage\".request_metadata->'{key}'").as_str())); + } + + assert!(sql.contains("END AS request_metadata")); + assert!(!sql.contains("\n \"usage\".request_metadata,\n")); + } +} + #[test] fn usage_sql_admin_record_filters_are_pushed_into_postgres_queries() { let source = include_str!("mod.rs"); diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index b56cc7175..b3fc97595 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -257,6 +257,8 @@ export interface RequestDetail { usage_pricing_available?: boolean input_audio_tokens?: number | null output_audio_tokens?: number | null + live_session?: Record | null + realtime_session?: Record | null upstream_is_stream?: boolean client_requested_stream?: boolean client_is_stream?: boolean diff --git a/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts b/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts index c3d0e6c86..b245f816c 100644 --- a/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts +++ b/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts @@ -61,11 +61,11 @@ describe('api format display helpers', () => { expect(apiFormatPermissionCovers('openai:responses:compact', 'openai:responses')).toBe(false) }) - it('keeps public Realtime and private Codex Live formats distinct', () => { + it('presents the private Codex Live protocol as OpenAI Live while keeping formats distinct', () => { expect(formatApiFormat(API_FORMATS.OPENAI_REALTIME)).toBe('OpenAI Realtime') expect(formatApiFormatShort(API_FORMATS.OPENAI_REALTIME)).toBe('ORT') - expect(formatApiFormat(API_FORMATS.CODEX_LIVE)).toBe('Codex Live') - expect(formatApiFormatShort(API_FORMATS.CODEX_LIVE)).toBe('CL') + expect(formatApiFormat(API_FORMATS.CODEX_LIVE)).toBe('OpenAI Live') + expect(formatApiFormatShort(API_FORMATS.CODEX_LIVE)).toBe('OL') expect(apiFormatPermissionCovers('openai:realtime', 'openai:realtime')).toBe(true) expect(apiFormatPermissionCovers('codex:live', 'codex:live')).toBe(true) expect(apiFormatPermissionCovers('openai:responses', 'openai:realtime')).toBe(false) diff --git a/frontend/src/api/endpoints/types/api-format.ts b/frontend/src/api/endpoints/types/api-format.ts index 6fb0c56df..2de035ffa 100644 --- a/frontend/src/api/endpoints/types/api-format.ts +++ b/frontend/src/api/endpoints/types/api-format.ts @@ -39,7 +39,7 @@ export const API_FORMAT_LABELS: Record = { [API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video', [API_FORMATS.OPENAI_EMBEDDING]: 'OpenAI Embedding', [API_FORMATS.OPENAI_RERANK]: 'OpenAI Rerank', - [API_FORMATS.CODEX_LIVE]: 'Codex Live', + [API_FORMATS.CODEX_LIVE]: 'OpenAI Live', [API_FORMATS.GEMINI_GENERATE_CONTENT]: 'Gemini Generate Content', [API_FORMATS.GEMINI_INTERACTIONS]: 'Gemini Interactions', [API_FORMATS.GEMINI_VIDEO]: 'Gemini Video', @@ -61,8 +61,8 @@ export const API_FORMAT_LABELS: Record = { OPENAI_VIDEO: 'OpenAI Video', OPENAI_EMBEDDING: 'OpenAI Embedding', OPENAI_RERANK: 'OpenAI Rerank', - CODEX_LIVE: 'Codex Live', - LIVE: 'Codex Live', + CODEX_LIVE: 'OpenAI Live', + LIVE: 'OpenAI Live', GEMINI: 'Gemini Generate Content', GEMINI_GENERATE_CONTENT: 'Gemini Generate Content', GEMINI_INTERACTIONS: 'Gemini Interactions', @@ -86,7 +86,7 @@ export const API_FORMAT_SHORT: Record = { [API_FORMATS.OPENAI_VIDEO]: 'OV', [API_FORMATS.OPENAI_EMBEDDING]: 'OE', [API_FORMATS.OPENAI_RERANK]: 'ORR', - [API_FORMATS.CODEX_LIVE]: 'CL', + [API_FORMATS.CODEX_LIVE]: 'OL', [API_FORMATS.CLAUDE_MESSAGES]: 'CM', [API_FORMATS.GEMINI_GENERATE_CONTENT]: 'G', [API_FORMATS.GEMINI_INTERACTIONS]: 'GI', @@ -107,8 +107,8 @@ export const API_FORMAT_SHORT: Record = { OPENAI_VIDEO: 'OV', OPENAI_EMBEDDING: 'OE', OPENAI_RERANK: 'ORR', - CODEX_LIVE: 'CL', - LIVE: 'CL', + CODEX_LIVE: 'OL', + LIVE: 'OL', CLAUDE: 'CM', CLAUDE_MESSAGES: 'CM', GEMINI: 'G', diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index 8999123d6..3e11b40b2 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -343,6 +343,7 @@ export const meApi = { timezone?: string tz_offset_minutes?: number search?: string // 通用搜索:密钥名、模型名 + api_format?: string status?: string limit?: number offset?: number diff --git a/frontend/src/features/providers/components/EndpointHealthTimeline.vue b/frontend/src/features/providers/components/EndpointHealthTimeline.vue index f1953bc8f..80eaacfcc 100644 --- a/frontend/src/features/providers/components/EndpointHealthTimeline.vue +++ b/frontend/src/features/providers/components/EndpointHealthTimeline.vue @@ -8,7 +8,7 @@ :lookback-hours="lookbackHours" :fallback-segments="segmentCount ?? GRID_COUNT" entity-label="端点" - :entity-name="monitor?.api_format" + :entity-name="monitor?.api_format ? formatApiFormat(monitor.api_format) : undefined" />
import { computed } from 'vue' import type { EndpointStatusMonitor, EndpointHealthEvent, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints' +import { formatApiFormat } from '@/api/endpoints/types/api-format' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import HealthStatusTimeline from './HealthStatusTimeline.vue' import { formatTimestamp, formatTimelineTooltip } from './health-monitor-utils' @@ -225,7 +226,7 @@ function buildSegmentTooltip( avg_tps: null }, entityLabel: '端点', - entityName: props.monitor?.api_format + entityName: props.monitor?.api_format ? formatApiFormat(props.monitor.api_format) : undefined }) } diff --git a/frontend/src/features/providers/components/PriorityManagementDialog.vue b/frontend/src/features/providers/components/PriorityManagementDialog.vue index 29f1fe9c8..eab75ab35 100644 --- a/frontend/src/features/providers/components/PriorityManagementDialog.vue +++ b/frontend/src/features/providers/components/PriorityManagementDialog.vue @@ -478,6 +478,7 @@ import { groupApiFormats, parseApiFormat, API_FORMAT_KIND_LABELS, + formatApiFormat, formatApiFormatShort, normalizeApiFormatAlias, } from '@/api/endpoints/types/api-format' @@ -573,7 +574,7 @@ function poolKeySummary(key: KeyWithMeta): string { } function emptyFormatKeyText(format: string): string { - return legacyT(`暂无 ${format} 格式的 Key`) + return legacyT(`暂无 ${formatApiFormat(format)} 格式的 Key`) } function localizedApiError(error: unknown, fallback: string): string { diff --git a/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue index 806ca806d..c79e884e0 100644 --- a/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue +++ b/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue @@ -373,6 +373,7 @@ import { type ProviderModelAlias, type ProviderMappingPreviewResponse, } from '@/api/endpoints' +import { formatApiFormat } from '@/api/endpoints/types/api-format' import { type EndpointAPIKey } from '@/api/endpoints/keys' import { updateModel } from '@/api/endpoints/models' import { useI18n } from '@/i18n' @@ -907,7 +908,7 @@ async function handleStartMappingTest() { await modelTest.startTest({ mode: 'direct', modelName: testingModelName.value, - displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`, + displayLabel: `[${formatApiFormat(endpoint.api_format)}] 映射 "${testingModelName.value}"`, apiFormat: endpoint.api_format, endpointId: endpoint.id, endpointBaseUrl: endpoint.base_url, diff --git a/frontend/src/features/providers/composables/__tests__/useEndpointStatus.spec.ts b/frontend/src/features/providers/composables/__tests__/useEndpointStatus.spec.ts new file mode 100644 index 000000000..03f1ae8c4 --- /dev/null +++ b/frontend/src/features/providers/composables/__tests__/useEndpointStatus.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { getEndpointTooltip } from '../useEndpointStatus' + +describe('getEndpointTooltip', () => { + it('formats the internal Codex Live id at the user-visible tooltip boundary', () => { + const tooltip = getEndpointTooltip({ + api_format: 'codex:live', + health_score: 1, + is_active: true, + total_keys: 1, + active_keys: 1, + }, 'zh-CN') + + expect(tooltip).toBe('OpenAI Live: 健康度 100%') + expect(tooltip).not.toContain('codex:live') + }) +}) diff --git a/frontend/src/features/providers/composables/useEndpointStatus.ts b/frontend/src/features/providers/composables/useEndpointStatus.ts index 75feb17f1..99caffcfb 100644 --- a/frontend/src/features/providers/composables/useEndpointStatus.ts +++ b/frontend/src/features/providers/composables/useEndpointStatus.ts @@ -1,5 +1,5 @@ import type { EndpointHealthDetail } from '@/api/endpoints' -import { compareApiFormats } from '@/api/endpoints/types/api-format' +import { compareApiFormats, formatApiFormat } from '@/api/endpoints/types/api-format' import { defaultLocale, translateLegacyText, type Locale } from '@/i18n/messages' // 端点状态枚举 @@ -58,7 +58,7 @@ export function getEndpointDotColor(endpoint: EndpointHealthDetail): string { * 端点提示文本 */ export function getEndpointTooltip(endpoint: EndpointHealthDetail, locale: Locale = defaultLocale): string { - const format = endpoint.api_format + const format = formatApiFormat(endpoint.api_format) const status = getEndpointStatus(endpoint) const t = (value: string) => translateLegacyText(value, locale) diff --git a/frontend/src/features/usage/components/ReplayDialog.vue b/frontend/src/features/usage/components/ReplayDialog.vue index ea5cb1a4d..3d973488f 100644 --- a/frontend/src/features/usage/components/ReplayDialog.vue +++ b/frontend/src/features/usage/components/ReplayDialog.vue @@ -258,7 +258,7 @@
Provider: {{ replayResult.mapping.target_provider }} Endpoint: {{ replayResult.mapping.target_endpoint_id }} - Format: {{ replayResult.mapping.target_api_format || '-' }} + Format: {{ formatApiFormat(replayResult.mapping.target_api_format) }}
{{ detail.status_code }} {{ detail.status_code }} @@ -58,27 +60,29 @@ :title="formatUsageWebSocketTransportTitle(detail)" class="border-sky-500/50 text-xs text-sky-600 dark:text-sky-400" > - {{ formatUsageWebSocketTransportLabel(detail) }} + WS - {{ resolveUsageStreamLabelSegments(detail).client }} + HTTP {{ resolveUsageStreamLabelSegments(detail).client }} - {{ resolveUsageStreamLabelSegments(detail).upstream }} + HTTP {{ resolveUsageStreamLabelSegments(detail).upstream }} - {{ formatUsageStreamLabel(detail) }} + HTTP {{ formatUsageStreamLabel(detail) }}
@@ -905,10 +909,7 @@ import { resolveDisplayRequestStatus, resolveUsageStreamLabelSegments, } from '../utils/status' -import { - formatUsageWebSocketTransportLabel, - formatUsageWebSocketTransportTitle, -} from '../utils/websocketTransport' +import { formatUsageWebSocketTransportTitle } from '../utils/websocketTransport' import { isCyberPolicyError } from '../utils/cyberError' import { mergeUsageRecordErrorMessage, diff --git a/frontend/src/features/usage/components/UsageRecordsTable.vue b/frontend/src/features/usage/components/UsageRecordsTable.vue index 4c6572a9a..63d0b3988 100644 --- a/frontend/src/features/usage/components/UsageRecordsTable.vue +++ b/frontend/src/features/usage/components/UsageRecordsTable.vue @@ -150,13 +150,13 @@ - 全部状态 + 全部类型 - 流式 + HTTP 流式 - 标准 + HTTP 标准 WebSocket (WS) @@ -287,7 +287,7 @@ :title="getWebSocketTransportTitle(record)" class="whitespace-nowrap border-sky-500/50 text-sky-600 dark:text-sky-400 text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0" > - {{ getWebSocketTransportLabel(record) }} + WS - {{ getWebSocketTransportLabel(record) }} + WS (() => [ ]) const statusFilterOptions: FilterOption[] = [ - { value: '__all__', label: '全部状态' }, - { value: 'stream', label: '流式' }, - { value: 'standard', label: '标准' }, + { value: '__all__', label: '全部类型' }, + { value: 'stream', label: 'HTTP 流式' }, + { value: 'standard', label: 'HTTP 标准' }, { value: 'websocket', label: 'WebSocket (WS)' }, { value: 'active', label: '活跃' }, { value: 'failed', label: '失败' }, @@ -1620,7 +1617,7 @@ function getApiFormatTooltip(record: UsageRecord): string { return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}` } - return record.api_format + return displayFormat } // 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version) diff --git a/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.pricing.spec.ts b/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.pricing.spec.ts index 0b577471c..e72abbea3 100644 --- a/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.pricing.spec.ts +++ b/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.pricing.spec.ts @@ -113,7 +113,7 @@ function buildFastTierDetail(): RequestDetail { } describe('RequestDetailDrawer settlement pricing', () => { - it('labels an unmetered Codex Live WebSocket detail without rendering zero usage as billing', async () => { + it('labels an unmetered OpenAI Live WebSocket detail without rendering zero usage as billing', async () => { apiMocks.getRequestDetail.mockResolvedValue({ ...buildEmbeddingDetail(), id: 'usage-live-ws-1', @@ -152,8 +152,11 @@ describe('RequestDetailDrawer settlement pricing', () => { await vi.waitFor(() => { const transport = document.body.querySelector('[data-usage-transport="websocket"]') - expect(transport?.textContent?.trim()).toBe('Live WS') - expect(transport?.title).toBe('Codex Live Sideband WebSocket') + expect(transport?.textContent?.trim()).toBe('WS') + expect(transport?.title).toBe('OpenAI Live Sideband WebSocket') + expect(document.body.querySelector('[data-usage-transport="http"]')).toBeNull() + expect(document.body.querySelector('[data-request-lifecycle-status]')?.textContent?.trim()) + .toBe('200') expect(document.body.querySelector('[data-request-detail-usage-unavailable]')).not.toBeNull() }) @@ -198,6 +201,8 @@ describe('RequestDetailDrawer settlement pricing', () => { await nextTick() await vi.waitFor(() => { + expect(document.body.querySelector('[data-usage-transport="http"]')?.textContent?.trim()) + .toBe('HTTP 流式') expect(document.body.textContent).toContain('10.12s / 10.63s') expect(document.body.textContent).toContain('95.1tps') expect(document.body.textContent).not.toContain('98.8tps') @@ -228,6 +233,8 @@ describe('RequestDetailDrawer settlement pricing', () => { await nextTick() await vi.waitFor(() => { + expect(document.body.querySelector('[data-usage-transport="http"]')?.textContent?.trim()) + .toBe('HTTP 标准') expect(document.body.textContent).toContain('输入 $0.1/M') expect(document.body.textContent).toContain('输出 -') }) diff --git a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts index 7b34a8456..58a8d5d1e 100644 --- a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts +++ b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts @@ -350,15 +350,30 @@ describe('UsageRecordsTable', () => { expect(root.textContent).toContain('gpt-5') }) - it('shows WS in the type column for completed WebSocket usage records', () => { + it('keeps the type label as WS after Responses detail enriches transport metadata', () => { const root = mountUsageRecordsTable([buildRecord({ is_websocket: true, + websocket_transport: 'responses', status: 'completed', })]) const badges = [...root.querySelectorAll('[data-usage-transport="websocket"]')] expect(badges.length).toBeGreaterThan(0) expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true) + expect(badges.every(badge => badge.title === 'OpenAI Responses WebSocket')).toBe(true) + }) + + it('falls back to WS when transport metadata is unavailable', () => { + const root = mountUsageRecordsTable([buildRecord({ + is_websocket: true, + websocket_transport: null, + status: 'completed', + })]) + + const badges = [...root.querySelectorAll('[data-usage-transport="websocket"]')] + expect(badges.length).toBeGreaterThan(0) + expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true) + expect(badges.every(badge => badge.title === 'WebSocket')).toBe(true) }) it('offers a WebSocket type filter in both filter menus', () => { @@ -367,9 +382,11 @@ describe('UsageRecordsTable', () => { const labels = [...root.querySelectorAll('div')] .filter(element => element.textContent?.trim() === 'WebSocket (WS)') expect(labels.length).toBeGreaterThanOrEqual(2) + expect(root.textContent).toContain('HTTP 流式') + expect(root.textContent).toContain('HTTP 标准') }) - it('labels Codex Live transport rows without inventing token or cost usage', () => { + it('keeps OpenAI Live transport rows under the single WS type without inventing usage', () => { const root = mountUsageRecordsTable([buildRecord({ is_websocket: true, websocket_transport: 'codex_live_direct', @@ -382,8 +399,8 @@ describe('UsageRecordsTable', () => { const badges = [...root.querySelectorAll('[data-usage-transport="websocket"]')] expect(badges.length).toBeGreaterThan(0) - expect(badges.every(badge => badge.textContent?.trim() === 'Live WS')).toBe(true) - expect(badges.every(badge => badge.title === 'Codex Live 直连 WebSocket')).toBe(true) + expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true) + expect(badges.every(badge => badge.title === 'OpenAI Live 直连 WebSocket')).toBe(true) const unavailableTokens = [...root.querySelectorAll('[data-usage-unavailable="tokens"]')] const unavailableCosts = [...root.querySelectorAll('[data-usage-unavailable="cost"]')] @@ -393,7 +410,7 @@ describe('UsageRecordsTable', () => { expect(unavailableCosts.every(element => element.textContent?.trim() === '不可用')).toBe(true) }) - it('labels OpenAI Realtime WebSocket rows consistently in both layouts', () => { + it('keeps OpenAI Realtime rows under the single WS type in both layouts', () => { const root = mountUsageRecordsTable([buildRecord({ is_websocket: true, websocket_transport: 'openai_realtime', @@ -401,7 +418,7 @@ describe('UsageRecordsTable', () => { const badges = [...root.querySelectorAll('[data-usage-transport="websocket"]')] expect(badges.length).toBeGreaterThan(0) - expect(badges.every(badge => badge.textContent?.trim() === 'Realtime WS')).toBe(true) + expect(badges.every(badge => badge.textContent?.trim() === 'WS')).toBe(true) expect(badges.every(badge => badge.title === 'OpenAI Realtime WebSocket')).toBe(true) }) @@ -603,6 +620,18 @@ describe('UsageRecordsTable', () => { expect(root.textContent).toContain('Gemini Embedding') expect(root.textContent).toContain('Jina Embedding') expect(root.textContent).toContain('Doubao Embedding') + const liveLabels = [...root.querySelectorAll('div')] + .filter(element => element.textContent?.trim() === 'OpenAI Live') + expect(liveLabels.length).toBeGreaterThanOrEqual(2) + expect(root.querySelector('[data-filter-value="codex:live"]')).not.toBeNull() + }) + + it('does not expose the canonical Codex Live id in row labels or tooltips', () => { + const root = mountUsageRecordsTable([buildRecord({ api_format: 'codex:live' })]) + + expect(root.textContent).toContain('OpenAI Live') + expect(root.textContent).not.toContain('codex:live') + expect(root.querySelector('[title="OpenAI Live"]')).not.toBeNull() }) it('emits hide unknown toggle changes', () => { diff --git a/frontend/src/features/usage/composables/__tests__/useUsageData.spec.ts b/frontend/src/features/usage/composables/__tests__/useUsageData.spec.ts index 05578c740..e51113ecf 100644 --- a/frontend/src/features/usage/composables/__tests__/useUsageData.spec.ts +++ b/frontend/src/features/usage/composables/__tests__/useUsageData.spec.ts @@ -143,11 +143,12 @@ describe('useUsageData', () => { await loadRecords( { page: 2, pageSize: 20 }, - { status: 'websocket' }, + { api_format: 'codex:live', status: 'websocket' }, { preset: 'today', tz_offset_minutes: 0 }, ) expect(meGetUsageMock).toHaveBeenCalledWith(expect.objectContaining({ + api_format: 'codex:live', status: 'websocket', limit: 20, offset: 20, diff --git a/frontend/src/features/usage/composables/useUsageData.ts b/frontend/src/features/usage/composables/useUsageData.ts index 31ddb1a5b..93c31824d 100644 --- a/frontend/src/features/usage/composables/useUsageData.ts +++ b/frontend/src/features/usage/composables/useUsageData.ts @@ -393,6 +393,9 @@ export function useUsageData(options: UseUsageDataOptions) { if (filters?.status) { params.status = filters.status } + if (filters?.api_format) { + params.api_format = filters.api_format + } if (isAdminPage.value) { // 管理员页面:使用管理员 API @@ -405,9 +408,6 @@ export function useUsageData(options: UseUsageDataOptions) { if (filters?.provider) { params.provider = filters.provider } - if (filters?.api_format) { - params.api_format = filters.api_format - } if (filters?.client_family) { params.client_family = filters.client_family } diff --git a/frontend/src/features/usage/utils/__tests__/recordFilterPolicy.spec.ts b/frontend/src/features/usage/utils/__tests__/recordFilterPolicy.spec.ts new file mode 100644 index 000000000..31f269840 --- /dev/null +++ b/frontend/src/features/usage/utils/__tests__/recordFilterPolicy.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { + isUserLocalOnlyRecordStatus, + shouldUseServerUserRecordFilters, +} from '../recordFilterPolicy' + +describe('normal-user usage record filter policy', () => { + it.each(['has_retry', 'has_fallback'] as const)( + 'keeps %s local even when combined with server-supported filters', + (status) => { + expect(isUserLocalOnlyRecordStatus(status)).toBe(true) + expect(shouldUseServerUserRecordFilters({ + search: 'production', + apiFormat: 'codex:live', + status, + })).toBe(false) + }, + ) + + it.each([ + { search: '', apiFormat: '__all__', status: 'websocket' as const }, + { search: '', apiFormat: 'codex:live', status: '__all__' as const }, + { search: 'production', apiFormat: '__all__', status: '__all__' as const }, + { search: 'production', apiFormat: 'codex:live', status: 'websocket' as const }, + ])('uses server pagination for supported filter combination %#', (filters) => { + expect(shouldUseServerUserRecordFilters(filters)).toBe(true) + }) + + it('keeps the unfiltered normal-user list on its existing local pagination path', () => { + expect(shouldUseServerUserRecordFilters({ + search: ' ', + apiFormat: '__all__', + status: '__all__', + })).toBe(false) + }) +}) diff --git a/frontend/src/features/usage/utils/__tests__/recordSearch.spec.ts b/frontend/src/features/usage/utils/__tests__/recordSearch.spec.ts new file mode 100644 index 000000000..5022253ab --- /dev/null +++ b/frontend/src/features/usage/utils/__tests__/recordSearch.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import type { UsageRecord } from '../../types' +import { matchesUsageRecordSearch } from '../recordSearch' + +function record(overrides: Partial = {}): UsageRecord { + return { + id: 'usage-1', + model: 'gpt-5.6-sol', + provider: 'OpenAI', + api_key: { + id: 'key-1', + name: 'Production Key', + display: 'Production Key', + }, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cost: 0, + is_stream: false, + created_at: '2026-08-21T00:00:00Z', + ...overrides, + } +} + +describe('matchesUsageRecordSearch', () => { + it('matches normal-user key and model search terms case-insensitively', () => { + const usage = record() + + expect(matchesUsageRecordSearch(usage, 'production')).toBe(true) + expect(matchesUsageRecordSearch(usage, 'GPT-5.6')).toBe(true) + expect(matchesUsageRecordSearch(usage, 'production gpt-5.6')).toBe(true) + }) + + it('requires every whitespace-delimited search term to match the record', () => { + const usage = record() + + expect(matchesUsageRecordSearch(usage, 'production missing')).toBe(false) + expect(matchesUsageRecordSearch(usage, ' ')).toBe(true) + }) +}) diff --git a/frontend/src/features/usage/utils/recordFilterPolicy.ts b/frontend/src/features/usage/utils/recordFilterPolicy.ts new file mode 100644 index 000000000..dd2966fb3 --- /dev/null +++ b/frontend/src/features/usage/utils/recordFilterPolicy.ts @@ -0,0 +1,17 @@ +import type { FilterStatusValue } from '../types' + +export function isUserLocalOnlyRecordStatus(status: FilterStatusValue): boolean { + return status === 'has_retry' || status === 'has_fallback' +} + +export function shouldUseServerUserRecordFilters(input: { + search: string + apiFormat: string + status: FilterStatusValue +}): boolean { + if (isUserLocalOnlyRecordStatus(input.status)) return false + + return input.search.trim().length > 0 + || input.apiFormat !== '__all__' + || input.status !== '__all__' +} diff --git a/frontend/src/features/usage/utils/recordSearch.ts b/frontend/src/features/usage/utils/recordSearch.ts new file mode 100644 index 000000000..0d9343e97 --- /dev/null +++ b/frontend/src/features/usage/utils/recordSearch.ts @@ -0,0 +1,41 @@ +import type { UsageRecord } from '../types' + +type SearchableUsageRecord = Pick< + UsageRecord, + | 'model' + | 'target_model' + | 'provider' + | 'api_key' + | 'api_key_name' + | 'provider_key_name' +> + +function normalizedSearchValues(record: SearchableUsageRecord): string[] { + return [ + record.model, + record.target_model, + record.provider, + record.api_key?.id, + record.api_key?.name, + record.api_key?.display, + record.api_key_name, + record.provider_key_name, + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .map(value => value.toLocaleLowerCase()) +} + +export function matchesUsageRecordSearch( + record: SearchableUsageRecord, + search: string, +): boolean { + const keywords = search + .trim() + .toLocaleLowerCase() + .split(/\s+/) + .filter(Boolean) + if (keywords.length === 0) return true + + const values = normalizedSearchValues(record) + return keywords.every(keyword => values.some(value => value.includes(keyword))) +} diff --git a/frontend/src/features/usage/utils/websocketTransport.ts b/frontend/src/features/usage/utils/websocketTransport.ts index 91a29d54e..5cec7b266 100644 --- a/frontend/src/features/usage/utils/websocketTransport.ts +++ b/frontend/src/features/usage/utils/websocketTransport.ts @@ -8,23 +8,6 @@ function normalizeWebSocketTransport( return record.websocket_transport?.trim().toLowerCase() ?? '' } -export function formatUsageWebSocketTransportLabel( - record: UsageWebSocketTransportLike, -): string { - switch (normalizeWebSocketTransport(record)) { - case 'responses': - return 'Responses WS' - case 'codex_live_direct': - case 'codex_live_sideband': - return 'Live WS' - case 'openai_realtime': - case 'realtime': - return 'Realtime WS' - default: - return 'WS' - } -} - export function formatUsageWebSocketTransportTitle( record: UsageWebSocketTransportLike, ): string { @@ -33,9 +16,9 @@ export function formatUsageWebSocketTransportTitle( case 'responses': return 'OpenAI Responses WebSocket' case 'codex_live_direct': - return 'Codex Live 直连 WebSocket' + return 'OpenAI Live 直连 WebSocket' case 'codex_live_sideband': - return 'Codex Live Sideband WebSocket' + return 'OpenAI Live Sideband WebSocket' case 'openai_realtime': case 'realtime': return 'OpenAI Realtime WebSocket' diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index a9a0d0e0c..5e1dd8859 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -705,6 +705,7 @@ const legacyExactEnglishMessages: Record = { '未找到匹配当前筛选条件的提供商': 'No providers match the current filters', '全部': 'All', '全部状态': 'All statuses', + '全部类型': 'All types', '全部格式': 'All formats', '全部模型': 'All models', '全部角色': 'All roles', @@ -998,6 +999,8 @@ const legacyExactEnglishMessages: Record = { '恢复默认': 'Restore defaults', '扩展模块': 'Extension modules', '流式': 'Streaming', + 'HTTP 流式': 'HTTP streaming', + 'HTTP 标准': 'HTTP standard', '转换': 'Conversion', '服务性能': 'Service performance', '有效权益': 'Active benefits', diff --git a/frontend/src/views/admin/PerformanceAnalysis.vue b/frontend/src/views/admin/PerformanceAnalysis.vue index 621d582d7..2a86e4eea 100644 --- a/frontend/src/views/admin/PerformanceAnalysis.vue +++ b/frontend/src/views/admin/PerformanceAnalysis.vue @@ -408,7 +408,7 @@ {{ item.context.provider_name || item.context.provider_id || '未知上游' }} - {{ item.context.api_format || item.context.model || '未知格式' }} + {{ item.context.api_format ? formatApiFormat(item.context.api_format) : item.context.model || '未知格式' }}
@@ -460,7 +460,7 @@
- {{ item.key_name || item.key_id }} · {{ item.api_format || '未知格式' }} + {{ item.key_name || item.key_id }} · {{ item.api_format ? formatApiFormat(item.api_format) : '未知格式' }}
@@ -849,6 +849,7 @@ import { type ProviderPerformanceItem, type ProviderPerformanceResponse, } from '@/api/admin' +import { formatApiFormat } from '@/api/endpoints/types/api-format' import { monitoringApi, type AdminMonitoringCircuitHistoryItem, diff --git a/frontend/src/views/shared/Usage.vue b/frontend/src/views/shared/Usage.vue index 70f750602..6c58617f2 100644 --- a/frontend/src/views/shared/Usage.vue +++ b/frontend/src/views/shared/Usage.vue @@ -170,6 +170,11 @@ import { normalizeRequestStatus, resolveDisplayRequestStatus, } from '@/features/usage/utils/status' +import { matchesUsageRecordSearch } from '@/features/usage/utils/recordSearch' +import { + isUserLocalOnlyRecordStatus, + shouldUseServerUserRecordFilters, +} from '@/features/usage/utils/recordFilterPolicy' import type { DateRangeParams, FilterStatusValue, RequestStatus, UsageRecord } from '@/features/usage/types' import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue' import { log } from '@/utils/logger' @@ -390,6 +395,10 @@ const filteredRecords = computed(() => { : [...currentRecords.value] if (!isAdminPage.value) { + if (isUserLocalOnlyRecordStatus(filterStatus.value) && filterSearch.value.trim()) { + records = records.filter(record => matchesUsageRecordSearch(record, filterSearch.value)) + } + if (filterModel.value !== '__all__') { records = records.filter(record => record.model === filterModel.value) } @@ -840,9 +849,21 @@ onUnmounted(() => { stopGlobalAutoRefresh() }) -// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选) +// Retry/fallback are derived from the locally loaded records and are not accepted by the +// normal-user records API. Keep those statuses entirely local, including when combined with +// search/API-format filters, so an unsupported status never produces a misleading server total. +// 普通用户的 API 格式/传输类型/搜索筛选由后端执行,避免只筛选当前已加载页。 +// 模型及后端不支持的 retry/fallback 筛选仍保持现有的本地分页语义。 +const userUsesServerRecordFilters = computed(() => !isAdminPage.value && ( + shouldUseServerUserRecordFilters({ + search: filterSearch.value, + apiFormat: filterApiFormat.value, + status: filterStatus.value, + }) +)) + const paginatedRecords = computed(() => { - if (!isAdminPage.value) { + if (!isAdminPage.value && !userUsesServerRecordFilters.value) { const start = (currentPage.value - 1) * pageSize.value const end = start + pageSize.value return filteredRecords.value.slice(start, end) @@ -852,7 +873,7 @@ const paginatedRecords = computed(() => { // 用户页面使用前端筛选后的总数,管理员页面使用后端返回的总数 const effectiveTotalRecords = computed(() => { - if (!isAdminPage.value) { + if (!isAdminPage.value && !userUsesServerRecordFilters.value) { return filteredRecords.value.length } return totalRecords.value @@ -930,26 +951,26 @@ async function handleTimeRangeChange(value: DateRangeParams) { return } await loadStats(timeRange.value) - // 用户页面:loadStats 已包含记录加载 + if (userUsesServerRecordFilters.value) { + await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) + } } // 处理分页变化 async function handlePageChange(page: number) { currentPage.value = page - if (isAdminPage.value) { + if (isAdminPage.value || userUsesServerRecordFilters.value) { await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) } - // 用户页面使用前端分页,无需重新请求 } // 处理每页大小变化 async function handlePageSizeChange(size: number) { pageSize.value = size currentPage.value = 1 // 重置到第一页 - if (isAdminPage.value) { + if (isAdminPage.value || userUsesServerRecordFilters.value) { await loadRecords({ page: 1, pageSize: size }, getCurrentFilters(), timeRange.value) } - // 用户页面使用前端分页,无需重新请求 } // 获取当前筛选参数 @@ -973,9 +994,11 @@ async function handleFilterSearchChange(value: string) { if (isAdminPage.value) { await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) + } else if (userUsesServerRecordFilters.value) { + await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) + } else { + await loadStats(timeRange.value) } - // 用户页面:search 需要重新从后端拉取数据(后端支持 search 参数) - // 但通过 filteredRecords 做前端过滤已覆盖,无需额外请求 } async function handleFilterUserChange(value: string) { @@ -1012,8 +1035,10 @@ async function handleFilterApiFormatChange(value: string) { filterApiFormat.value = value currentPage.value = 1 - if (isAdminPage.value) { + if (isAdminPage.value || userUsesServerRecordFilters.value) { await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) + } else { + await loadStats(timeRange.value) } } @@ -1021,8 +1046,10 @@ async function handleFilterStatusChange(value: string) { filterStatus.value = value as FilterStatusValue currentPage.value = 1 - if (isAdminPage.value) { + if (isAdminPage.value || userUsesServerRecordFilters.value) { await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) + } else { + await loadStats(timeRange.value) } } @@ -1041,7 +1068,7 @@ async function refreshData() { if (refreshInFlight) return refreshInFlight refreshInFlight = (async () => { - if (isAdminPage.value) { + if (isAdminPage.value || userUsesServerRecordFilters.value) { await loadRecords( { page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters(), @@ -1051,7 +1078,6 @@ async function refreshData() { } await loadStats(timeRange.value) - // 用户页面:loadStats 已包含记录加载 })() try { diff --git a/frontend/src/views/shared/__tests__/Usage.record-filters.spec.ts b/frontend/src/views/shared/__tests__/Usage.record-filters.spec.ts new file mode 100644 index 000000000..269d8c575 --- /dev/null +++ b/frontend/src/views/shared/__tests__/Usage.record-filters.spec.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + resolve(process.cwd(), 'src/views/shared/Usage.vue'), + 'utf8', +) + +function functionBlock(name: string, nextName: string): string { + return source.split(`async function ${name}`)[1]?.split(`async function ${nextName}`)[0] ?? '' +} + +describe('usage record server filters', () => { + it('uses server pagination for normal-user API format and transport filters', () => { + expect(source).toContain('shouldUseServerUserRecordFilters({') + expect(source).toContain('!isAdminPage.value && !userUsesServerRecordFilters.value') + + const apiFormatHandler = functionBlock('handleFilterApiFormatChange', 'handleFilterStatusChange') + const statusHandler = functionBlock('handleFilterStatusChange', 'handleFilterClientFamilyChange') + expect(apiFormatHandler).toContain('isAdminPage.value || userUsesServerRecordFilters.value') + expect(apiFormatHandler).toContain('await loadRecords(') + expect(statusHandler).toContain('isAdminPage.value || userUsesServerRecordFilters.value') + expect(statusHandler).toContain('await loadRecords(') + }) + + it('keeps filtered normal-user pagination and refreshes on the server', () => { + const pageHandler = functionBlock('handlePageChange', 'handlePageSizeChange') + const pageSizeHandler = functionBlock('handlePageSizeChange', 'handleFilterSearchChange') + const refreshHandler = functionBlock('refreshData', 'handleManualRefresh') + + expect(pageHandler).toContain('isAdminPage.value || userUsesServerRecordFilters.value') + expect(pageSizeHandler).toContain('isAdminPage.value || userUsesServerRecordFilters.value') + expect(refreshHandler).toContain('isAdminPage.value || userUsesServerRecordFilters.value') + expect(refreshHandler).toContain('await loadRecords(') + }) + + it('keeps retry and fallback filters local because the user API does not accept them', () => { + expect(source).toContain('isUserLocalOnlyRecordStatus(filterStatus.value)') + expect(source).toContain('matchesUsageRecordSearch(record, filterSearch.value)') + + const statusHandler = functionBlock('handleFilterStatusChange', 'handleFilterClientFamilyChange') + expect(statusHandler).toContain('await loadStats(timeRange.value)') + }) +})