From f960bbd2c8307cd56a72c04e06b8b3de126301d0 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Mon, 21 Sep 2026 09:51:33 +0800 Subject: [PATCH 1/4] feat: expose upstream response model in usage records --- .../handlers/public/support/user_me_usage.rs | 21 ++++ .../aether-admin/src/observability/usage.rs | 28 +++++ .../list_recent_usage_audits_prefix.sql | 3 + .../queries/list_usage_audits_prefix.sql | 3 + .../adapters/postgres/src/usage/tests.rs | 2 + .../src/repository/usage/metadata_policy.rs | 30 ++++- .../contracts/src/repository/usage/mod.rs | 19 +-- .../contracts/src/repository/usage/types.rs | 103 +++++++++++++++- crates/aether-usage/runtime/src/event_wire.rs | 13 +- crates/aether-usage/runtime/src/record.rs | 14 ++- .../runtime/src/request_metadata.rs | 116 +++++++++++++++++- crates/aether-usage/runtime/src/runtime.rs | 16 ++- crates/aether-usage/runtime/src/write.rs | 28 ++++- frontend/src/api/dashboard.ts | 1 + frontend/src/api/me.ts | 3 + frontend/src/api/usage.ts | 3 + frontend/src/api/usageRecords.ts | 1 + .../usage/components/RequestDetailDrawer.vue | 8 +- .../usage/components/UsageModelDisplay.vue | 42 +++++-- .../usage/components/UsageRecordsTable.vue | 27 ++-- .../RequestDetailDrawer.pricing.spec.ts | 17 ++- .../__tests__/UsageRecordsTable.spec.ts | 33 ++++- .../usage/composables/useUsageData.ts | 8 +- frontend/src/views/shared/Usage.vue | 10 ++ 24 files changed, 466 insertions(+), 83 deletions(-) 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 5052254bc..9ab057b8d 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 @@ -567,6 +567,7 @@ fn build_users_me_usage_record_payload( "id": item.id, "model": item.model, "target_model": serde_json::Value::Null, + "response_model": item.provider_response_model(), "api_format": item.api_format, "endpoint_api_format": item.endpoint_api_format, "has_format_conversion": item.has_format_conversion, @@ -681,6 +682,7 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_ "client_ip": users_me_usage_metadata_string(item, "client_ip"), "user_agent": users_me_usage_metadata_string(item, "user_agent"), "target_model": item.target_model, + "response_model": item.provider_response_model(), "has_fallback": item.has_fallback(), }); payload["end_to_end_time_ms"] = json!(users_me_usage_metadata_u64(item, "end_to_end_time_ms")); @@ -1861,6 +1863,25 @@ mod tests { assert_eq!(payload["cache_creation_ephemeral_1h_input_tokens"], 6); } + #[test] + fn user_usage_payloads_expose_response_model_separately_from_mapping() { + let item = StoredRequestUsageAudit { + target_model: Some("provider-mapped-model".to_string()), + request_metadata: Some(json!({ + "provider_response_model": "gpt-5.1" + })), + ..sample_usage("completed") + }; + + let record = build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false); + let active = build_users_me_usage_active_payload(&item); + + for payload in [&record, &active] { + assert_eq!(payload["target_model"], "provider-mapped-model"); + assert_eq!(payload["response_model"], "gpt-5.1"); + } + } + #[test] fn user_usage_payloads_project_end_to_end_timings_from_metadata() { let item = StoredRequestUsageAudit { diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs index 8a604590f..a1cadfef4 100644 --- a/crates/aether-admin/src/observability/usage.rs +++ b/crates/aether-admin/src/observability/usage.rs @@ -1349,6 +1349,7 @@ fn admin_usage_active_request_json( if let Some(target_model) = item.target_model.as_ref() { value["target_model"] = json!(target_model); } + value["response_model"] = json!(item.provider_response_model()); if let Some(reasoning_effort) = item.provider_reasoning_effort() { value["reasoning_effort"] = json!(reasoning_effort); } @@ -1408,6 +1409,7 @@ pub fn admin_usage_record_json( "provider": item.provider_name, "model": item.model, "target_model": item.target_model, + "response_model": item.provider_response_model(), "input_tokens": item.input_tokens, "effective_input_tokens": admin_usage_effective_input_tokens(item), "output_tokens": item.output_tokens, @@ -2842,6 +2844,32 @@ mod tests { assert_eq!(record["client_is_stream"], false); } + #[test] + fn admin_usage_payloads_expose_response_model_separately_from_mapping() { + let item = StoredRequestUsageAudit { + target_model: Some("provider-mapped-model".to_string()), + request_metadata: Some(json!({ + "provider_response_model": "gpt-5.1" + })), + ..sample_usage("completed", Some(200), None) + }; + + let record = admin_usage_record_json( + &item, + &BTreeMap::new(), + &BTreeMap::new(), + false, + false, + None, + ); + let active = admin_usage_active_request_json(&item, None, None, None); + + for payload in [&record, &active] { + assert_eq!(payload["target_model"], "provider-mapped-model"); + assert_eq!(payload["response_model"], "gpt-5.1"); + } + } + #[test] fn admin_usage_payloads_project_end_to_end_timings_from_metadata() { let item = StoredRequestUsageAudit { 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 d0909bcd2..d6d8a736d 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 @@ -183,6 +183,7 @@ SELECT OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), '') IS NOT NULL + OR NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), '') IS NOT NULL 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') @@ -208,6 +209,8 @@ SELECT NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), ''), 'provider_actual_service_tier', NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), ''), + 'provider_response_model', + NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), ''), 'client_requested_stream', CASE WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') 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 d0909bcd2..d6d8a736d 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 @@ -183,6 +183,7 @@ SELECT OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), '') IS NOT NULL + OR NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), '') IS NOT NULL 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') @@ -208,6 +209,8 @@ SELECT NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), ''), 'provider_actual_service_tier', NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), ''), + 'provider_response_model', + NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), ''), 'client_requested_stream', CASE WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index ee140363b..33a1b01e3 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -3611,6 +3611,8 @@ fn usage_sql_uses_json_null_placeholders_for_usage_payload_columns() { assert!(sql.contains("request_metadata->>'provider_reasoning_effort'")); assert!(sql.contains("request_metadata->>'provider_service_tier'")); assert!(sql.contains("request_metadata->>'provider_actual_service_tier'")); + assert!(sql.contains("request_metadata->>'provider_response_model'")); + assert!(sql.contains("'provider_response_model'")); assert!(sql.contains("request_metadata->>'websocket_mode'")); assert!(sql.contains("'websocket_mode'")); assert!(sql.contains("AS client_family")); diff --git a/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs b/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs index 70b2fdca2..a559c2b36 100644 --- a/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs +++ b/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs @@ -8,9 +8,10 @@ use serde_json::{Map, Value}; use crate::repository::candidates::sanitize_request_candidate_skip_reason; use super::{ - LIVE_SESSION_METADATA_KEY, PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, - PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, - PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, + normalize_provider_response_model, LIVE_SESSION_METADATA_KEY, + PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, + PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, + PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY, @@ -94,6 +95,12 @@ pub fn sanitize_usage_request_metadata_object(source: &Map) -> Op ] { insert_known_string(source, &mut target, key, sanitize_service_tier); } + insert_known_string( + source, + &mut target, + PROVIDER_RESPONSE_MODEL_METADATA_KEY, + normalize_provider_response_model, + ); insert_bounded_u64( source, &mut target, @@ -1279,6 +1286,23 @@ mod tests { } } + #[test] + fn persistence_projection_keeps_bounded_response_model_only_as_a_string() { + let metadata = sanitize_usage_request_metadata(Some(json!({ + "provider_response_model": " GPT-5.1 " + }))) + .expect("response model should remain"); + assert_eq!(metadata["provider_response_model"], "GPT-5.1"); + assert!(sanitize_usage_request_metadata(Some(json!({ + "provider_response_model": 42 + }))) + .is_none()); + assert!(sanitize_usage_request_metadata(Some(json!({ + "provider_response_model": "x".repeat(257) + }))) + .is_none()); + } + #[test] fn persistence_projection_keeps_only_bounded_settlement_facts() { let metadata = sanitize_usage_request_metadata(Some(json!({ diff --git a/crates/aether-data/contracts/src/repository/usage/mod.rs b/crates/aether-data/contracts/src/repository/usage/mod.rs index ed2377a3a..b205badf4 100644 --- a/crates/aether-data/contracts/src/repository/usage/mod.rs +++ b/crates/aether-data/contracts/src/repository/usage/mod.rs @@ -15,11 +15,12 @@ pub use policy::*; pub use types::{ canonical_usage_body_ref_for, extract_provider_actual_service_tier_from_response, extract_provider_cache_ttl_minutes_from_metadata, extract_provider_reasoning_effort_from_body, - extract_provider_service_tier_from_body, normalize_provider_service_tier, parse_usage_body_ref, + extract_provider_response_model_from_bodies, extract_provider_service_tier_from_body, + normalize_provider_response_model, normalize_provider_service_tier, parse_usage_body_ref, resolve_provider_cache_ttl_minutes, resolve_provider_service_tier_from_request_capture, - usage_body_ref, usage_request_metadata_client_family, ApiKeyLastUsedDelta, - ManagementTokenCounterDelta, PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest, - ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary, + usage_body_capture_is_authoritative, usage_body_ref, usage_request_metadata_client_family, + ApiKeyLastUsedDelta, ManagementTokenCounterDelta, PendingUsageCleanupSummary, + ProviderApiKeyWindowUsageRequest, ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary, StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow, StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary, StoredUsageBodyPayload, StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary, @@ -46,9 +47,9 @@ pub use types::{ UsageTimeSeriesQuery, UsageWriteRepository, LIVE_SESSION_METADATA_KEY, PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, - PROVIDER_SERVICE_TIER_METADATA_KEY, REALTIME_SESSION_METADATA_KEY, - REQUESTED_REASONING_EFFORT_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, - ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, - USAGE_PRICING_AVAILABLE_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY, - WEBSOCKET_TRANSPORT_METADATA_KEY, + PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, + REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY, + ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, + USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY, + WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_METADATA_KEY, }; diff --git a/crates/aether-data/contracts/src/repository/usage/types.rs b/crates/aether-data/contracts/src/repository/usage/types.rs index c266625e8..7382b6867 100644 --- a/crates/aether-data/contracts/src/repository/usage/types.rs +++ b/crates/aether-data/contracts/src/repository/usage/types.rs @@ -6,6 +6,7 @@ pub const PROVIDER_REASONING_EFFORT_METADATA_KEY: &str = "provider_reasoning_eff pub const REQUESTED_REASONING_EFFORT_METADATA_KEY: &str = "requested_reasoning_effort"; pub const PROVIDER_SERVICE_TIER_METADATA_KEY: &str = "provider_service_tier"; pub const PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY: &str = "provider_actual_service_tier"; +pub const PROVIDER_RESPONSE_MODEL_METADATA_KEY: &str = "provider_response_model"; pub const PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY: &str = "provider_cache_ttl_minutes"; pub const ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY: &str = "routing_candidate_skip_reason"; pub const ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY: &str = "routing_failure_diagnostic"; @@ -119,6 +120,42 @@ pub fn normalize_provider_service_tier(value: &str) -> Option { Some(value.to_ascii_lowercase()) } +/// 清洗响应体顶层 `model`,保留大小写,只去除首尾空白。 +pub fn normalize_provider_response_model(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value.len() > 256 { + return None; + } + Some(value.to_string()) +} + +/// 只有请求体和响应体都可作为完整事实时,才计算响应模型,避免用截断内容猜测。 +pub fn extract_provider_response_model_from_bodies( + request_body: Option<&Value>, + request_body_state: Option, + response_body: Option<&Value>, + response_body_state: Option, +) -> Option { + if !usage_body_capture_is_authoritative(request_body, request_body_state) + || !usage_body_capture_is_authoritative(response_body, response_body_state) + { + return None; + } + + let request_model = request_body + .and_then(Value::as_object) + .and_then(|body| body.get("model")) + .and_then(Value::as_str) + .and_then(normalize_provider_response_model)?; + let response_model = response_body + .and_then(Value::as_object) + .and_then(|body| body.get("model")) + .and_then(Value::as_str) + .and_then(normalize_provider_response_model)?; + + (request_model != response_model).then_some(response_model) +} + /// Resolves a provider processing tier exclusively from the final upstream request. /// /// A complete captured body is authoritative, including when it contains no tier. The metadata @@ -129,7 +166,7 @@ pub fn resolve_provider_service_tier_from_request_capture( provider_request_body_state: Option, request_metadata: Option<&Value>, ) -> Option { - if request_body_capture_is_authoritative(provider_request_body, provider_request_body_state) { + if usage_body_capture_is_authoritative(provider_request_body, provider_request_body_state) { return extract_provider_service_tier_from_body(provider_request_body); } @@ -153,7 +190,7 @@ pub fn resolve_provider_service_tier_from_request_capture( .and_then(normalize_provider_service_tier) } -fn request_body_capture_is_authoritative( +pub fn usage_body_capture_is_authoritative( request_body: Option<&Value>, request_body_state: Option, ) -> bool { @@ -184,7 +221,7 @@ fn resolve_reasoning_effort_from_request_capture( request_metadata: Option<&Value>, metadata_key: &str, ) -> Option { - if request_body_capture_is_authoritative(request_body, request_body_state) { + if usage_body_capture_is_authoritative(request_body, request_body_state) { return extract_provider_reasoning_effort_from_body(request_body); } @@ -701,6 +738,11 @@ impl StoredRequestUsageAudit { }) } + pub fn provider_response_model(&self) -> Option { + self.request_metadata_string(PROVIDER_RESPONSE_MODEL_METADATA_KEY) + .and_then(normalize_provider_response_model) + } + pub fn provider_cache_ttl_minutes(&self) -> Option { resolve_provider_cache_ttl_minutes( self.endpoint_api_format @@ -2646,7 +2688,8 @@ fn parse_timestamp(value: i64, field_name: &str) -> Result {} } metadata = attach_provider_response_body_metadata(metadata, data.response_body.as_ref()); + metadata = attach_provider_response_model_metadata( + metadata, + request_body, + data.request_body_state, + data.response_body.as_ref(), + data.response_body_state, + ); // Billing reads raw-body TTL before metadata regardless of capture state. // Preserve that precedence independently of reasoning and tier authority. if let Some(cache_ttl) = body_cache_ttl { diff --git a/crates/aether-usage/runtime/src/record.rs b/crates/aether-usage/runtime/src/record.rs index ac46cc482..8433168ab 100644 --- a/crates/aether-usage/runtime/src/record.rs +++ b/crates/aether-usage/runtime/src/record.rs @@ -5,9 +5,9 @@ use aether_data_contracts::DataLayerError; use crate::request_metadata::{ attach_client_request_body_metadata, attach_provider_request_body_metadata, - clear_client_request_body_metadata, clear_provider_request_body_metadata, - request_body_derived_facts_action, sanitize_usage_request_metadata, - RequestBodyDerivedFactsAction, + attach_provider_response_model_metadata, clear_client_request_body_metadata, + clear_provider_request_body_metadata, request_body_derived_facts_action, + sanitize_usage_request_metadata, RequestBodyDerivedFactsAction, }; use crate::{UsageEvent, UsageEventType}; @@ -85,6 +85,14 @@ pub fn build_upsert_usage_record_from_event( } RequestBodyDerivedFactsAction::Preserve => {} } + // 响应模型必须在 body 被裁剪前从客户端请求体和上游响应体共同派生;缺少权威 body 时保留已派生事实。 + data.request_metadata = attach_provider_response_model_metadata( + data.request_metadata, + data.request_body.as_ref(), + data.request_body_state, + data.response_body.as_ref(), + data.response_body_state, + ); let now_unix_secs = event.timestamp_ms / 1_000; Ok(UpsertUsageRecord { diff --git a/crates/aether-usage/runtime/src/request_metadata.rs b/crates/aether-usage/runtime/src/request_metadata.rs index 33f7b5e0f..925e60999 100644 --- a/crates/aether-usage/runtime/src/request_metadata.rs +++ b/crates/aether-usage/runtime/src/request_metadata.rs @@ -1,13 +1,15 @@ use aether_contracts::ExecutionPlan; use aether_data_contracts::repository::usage::{ extract_provider_actual_service_tier_from_response, - extract_provider_reasoning_effort_from_body, extract_provider_service_tier_from_body, - normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, + extract_provider_reasoning_effort_from_body, extract_provider_response_model_from_bodies, + extract_provider_service_tier_from_body, normalize_provider_service_tier, + resolve_provider_cache_ttl_minutes, sanitize_usage_request_metadata as project_usage_request_metadata, sanitize_usage_request_metadata_object as project_usage_request_metadata_object, sanitize_usage_request_metadata_ref as project_usage_request_metadata_ref, - UsageBodyCaptureState, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, - PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, + usage_body_capture_is_authoritative, UsageBodyCaptureState, + PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, + PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY, }; use serde_json::{Map, Value}; @@ -249,6 +251,75 @@ pub(crate) fn attach_provider_response_body_metadata( attach_provider_actual_service_tier_metadata(metadata, actual_service_tier.as_deref()) } +pub(crate) fn attach_provider_response_model_metadata( + metadata: Option, + request_body: Option<&Value>, + request_body_state: Option, + response_body: Option<&Value>, + response_body_state: Option, +) -> Option { + let both_bodies_are_authoritative = + usage_body_capture_is_authoritative(request_body, request_body_state) + && usage_body_capture_is_authoritative(response_body, response_body_state); + let response_model = extract_provider_response_model_from_bodies( + request_body, + request_body_state, + response_body, + response_body_state, + ); + if !both_bodies_are_authoritative && response_model.is_none() { + return metadata; + } + + let mut object = match metadata { + Some(Value::Object(object)) => object, + _ => Map::new(), + }; + // 完整终态 body 是最终候选的权威事实;相同、无效或缺失模型都要清除旧候选值。 + if both_bodies_are_authoritative { + object.remove(PROVIDER_RESPONSE_MODEL_METADATA_KEY); + } + if let Some(response_model) = response_model { + object.insert( + PROVIDER_RESPONSE_MODEL_METADATA_KEY.to_string(), + Value::String(response_model), + ); + } + (!object.is_empty()).then_some(Value::Object(object)) +} + +/// 终态候选无法完成比较时,显式清除旧响应模型,避免重试/故障转移残留。 +pub(crate) fn refresh_provider_response_model_metadata( + metadata: Option, + request_body: Option<&Value>, + request_body_state: Option, + response_body: Option<&Value>, + response_body_state: Option, +) -> Option { + let mut object = match metadata { + Some(Value::Object(object)) => object, + _ => Map::new(), + }; + object.remove(PROVIDER_RESPONSE_MODEL_METADATA_KEY); + + if usage_body_capture_is_authoritative(request_body, request_body_state) + && usage_body_capture_is_authoritative(response_body, response_body_state) + { + if let Some(response_model) = extract_provider_response_model_from_bodies( + request_body, + request_body_state, + response_body, + response_body_state, + ) { + object.insert( + PROVIDER_RESPONSE_MODEL_METADATA_KEY.to_string(), + Value::String(response_model), + ); + } + } + (!object.is_empty()).then_some(Value::Object(object)) +} + /// Refreshes the response-derived tier for a terminal snapshot. Complete response objects are /// authoritative even when they contain no tier (which clears a stale candidate value). Capture /// placeholders/absent bodies are not authoritative, so a terminal summary already present in @@ -323,8 +394,9 @@ mod tests { use super::{ attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata, attach_provider_request_body_metadata, attach_provider_response_body_metadata, - build_usage_request_metadata_seed, merge_usage_request_metadata, - merge_usage_request_metadata_owned, refresh_provider_response_body_metadata, + attach_provider_response_model_metadata, build_usage_request_metadata_seed, + merge_usage_request_metadata, merge_usage_request_metadata_owned, + refresh_provider_response_body_metadata, refresh_provider_response_model_metadata, retain_first_byte_request_metadata, sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref, }; @@ -802,6 +874,38 @@ mod tests { ); } + #[test] + fn response_model_metadata_is_independent_from_mapping_and_clears_stale_values() { + let metadata = attach_provider_response_model_metadata( + Some(json!({"provider_response_model": "old-model", "trace_id": "trace-1"})), + Some(&json!({"model": "gpt-5"})), + Some(UsageBodyCaptureState::Inline), + Some(&json!({"model": "gpt-5.1"})), + Some(UsageBodyCaptureState::Inline), + ) + .expect("response model should be attached"); + assert_eq!(metadata["provider_response_model"], "gpt-5.1"); + assert_eq!(metadata["trace_id"], "trace-1"); + + let metadata = refresh_provider_response_model_metadata( + Some(json!({"provider_response_model": "gpt-5.1"})), + Some(&json!({"model": "gpt-5"})), + Some(UsageBodyCaptureState::Inline), + Some(&json!({"model": "gpt-5"})), + Some(UsageBodyCaptureState::Inline), + ); + assert!(metadata.is_none()); + + let metadata = refresh_provider_response_model_metadata( + Some(json!({"provider_response_model": "gpt-5.1"})), + None, + Some(UsageBodyCaptureState::Disabled), + Some(&json!({"model": "gpt-5.2"})), + Some(UsageBodyCaptureState::Inline), + ); + assert!(metadata.is_none()); + } + #[test] fn terminal_response_refresh_replaces_stale_actual_tier() { let metadata = refresh_provider_response_body_metadata( diff --git a/crates/aether-usage/runtime/src/runtime.rs b/crates/aether-usage/runtime/src/runtime.rs index 99706f638..9b1152f95 100644 --- a/crates/aether-usage/runtime/src/runtime.rs +++ b/crates/aether-usage/runtime/src/runtime.rs @@ -21,9 +21,10 @@ use crate::executor::spawn_on_usage_background_runtime; use crate::queue::is_permanent_enqueue_error; use crate::request_metadata::{ attach_client_request_body_metadata, attach_provider_request_body_metadata, - attach_provider_response_body_metadata, clear_client_request_body_metadata, - clear_provider_request_body_metadata, request_body_derived_facts_action, - retain_first_byte_request_metadata, RequestBodyDerivedFactsAction, + attach_provider_response_body_metadata, attach_provider_response_model_metadata, + clear_client_request_body_metadata, clear_provider_request_body_metadata, + request_body_derived_facts_action, retain_first_byte_request_metadata, + RequestBodyDerivedFactsAction, }; use crate::settlement::{ reconcile_usage_policy_cost_for_event_with_result, settle_usage_with_reconciled_cost, @@ -5297,8 +5298,15 @@ fn preserve_request_facts_with_legacy_missing( fn preserve_provider_response_facts(event: &mut UsageEvent) { let metadata = event.data.request_metadata.take(); - event.data.request_metadata = + let metadata = attach_provider_response_body_metadata(metadata, event.data.response_body.as_ref()); + event.data.request_metadata = attach_provider_response_model_metadata( + metadata, + event.data.request_body.as_ref(), + event.data.request_body_state, + event.data.response_body.as_ref(), + event.data.response_body_state, + ); } impl UsageQueueHealthSnapshot { diff --git a/crates/aether-usage/runtime/src/write.rs b/crates/aether-usage/runtime/src/write.rs index 0ec9e393e..dc193c0c3 100644 --- a/crates/aether-usage/runtime/src/write.rs +++ b/crates/aether-usage/runtime/src/write.rs @@ -17,9 +17,10 @@ use crate::body_capture::{ }; use crate::request_metadata::{ attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata, - attach_provider_request_body_metadata, build_usage_request_metadata_seed, - merge_usage_request_metadata, merge_usage_request_metadata_owned, - refresh_provider_response_body_metadata, sanitize_usage_request_metadata, + attach_provider_request_body_metadata, attach_provider_response_model_metadata, + build_usage_request_metadata_seed, merge_usage_request_metadata, + merge_usage_request_metadata_owned, refresh_provider_response_body_metadata, + refresh_provider_response_model_metadata, sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref, }; use crate::{ @@ -724,6 +725,13 @@ fn build_terminal_usage_event_from_seed_impl( Some(model.as_str()), provider_request.as_ref(), ); + let request_metadata = attach_provider_response_model_metadata( + request_metadata, + request_body.as_ref(), + body_states.request_body_state, + provider_response.as_ref(), + body_states.response_body_state, + ); let mut data = UsageEventData { user_id, @@ -1047,6 +1055,13 @@ pub fn build_sync_terminal_usage_seed( context_seed.request_metadata, provider_response_full.as_ref(), ); + let request_metadata = refresh_provider_response_model_metadata( + request_metadata, + context_seed.request_body.as_ref(), + context_seed.body_states.request_body_state, + provider_response_full.as_ref(), + provider_response_body_state, + ); TerminalUsageSeed { terminal_state, @@ -1225,6 +1240,13 @@ pub fn build_stream_terminal_usage_seed( context_seed.request_metadata, provider_response_full.as_ref(), ); + let request_metadata = refresh_provider_response_model_metadata( + request_metadata, + context_seed.request_body.as_ref(), + context_seed.body_states.request_body_state, + provider_response_full.as_ref(), + provider_response_body_state, + ); // The parser's terminal summary is authoritative when a response body is truncated or the // body and summary disagree; attach it after the body refresh so it wins. let request_metadata = attach_provider_actual_service_tier_metadata( diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 44e6867eb..4d36ba1f8 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -219,6 +219,7 @@ export interface RequestDetail { has_format_conversion?: boolean | null model: string target_model?: string | null // 映射后的目标模型名 + response_model?: string | null // 上游响应体实际返回的模型名 requested_reasoning_effort?: string | null reasoning_effort?: string | null service_tier?: string | null diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index a816c9875..b1509c05f 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -60,6 +60,7 @@ export interface UsageRecordDetail { reasoning_effort?: string | null service_tier?: string | null actual_service_tier?: string | null + response_model?: string | null input_tokens: number effective_input_tokens?: number output_tokens: number @@ -392,6 +393,7 @@ export const meApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null @@ -438,6 +440,7 @@ export const meApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 88f67a499..23cab0b21 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -20,6 +20,7 @@ export interface UsageRecord { reasoning_effort?: string | null service_tier?: string | null actual_service_tier?: string | null + response_model?: string | null input_tokens: number effective_input_tokens?: number output_tokens: number @@ -618,6 +619,7 @@ export const usageApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null @@ -685,6 +687,7 @@ export const usageApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null diff --git a/frontend/src/api/usageRecords.ts b/frontend/src/api/usageRecords.ts index 8ef0b4150..d6814a2da 100644 --- a/frontend/src/api/usageRecords.ts +++ b/frontend/src/api/usageRecords.ts @@ -19,6 +19,7 @@ export interface UsageRecord { model: string target_model?: string | null // 映射后的目标模型名(若无映射则为空) model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段) + response_model?: string | null // 上游响应体实际返回的模型名 request_type?: string | null // 由请求语义识别出的操作类型 requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系 reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别 diff --git a/frontend/src/features/usage/components/RequestDetailDrawer.vue b/frontend/src/features/usage/components/RequestDetailDrawer.vue index f67ac97f6..14f142760 100644 --- a/frontend/src/features/usage/components/RequestDetailDrawer.vue +++ b/frontend/src/features/usage/components/RequestDetailDrawer.vue @@ -1016,6 +1016,7 @@ const emit = defineEmits<{ reasoningEffort?: string | null serviceTier?: string | null actualServiceTier?: string | null + responseModel?: string | null imageProgress?: ImageProgress | null errorMessage?: string | null updatedAt?: string | null @@ -1158,6 +1159,7 @@ type HeaderModelTextField = | 'model' | 'target_model' | 'model_version' + | 'response_model' | 'request_type' | 'requested_reasoning_effort' | 'reasoning_effort' @@ -1166,6 +1168,7 @@ type HeaderModelTextField = const FINAL_PROVIDER_HEADER_FIELDS = new Set([ 'target_model', + 'response_model', 'reasoning_effort', 'service_tier', 'actual_service_tier', @@ -1252,6 +1255,7 @@ watch( props.summaryRecord?.model, props.summaryRecord?.target_model, props.summaryRecord?.model_version, + props.summaryRecord?.response_model, props.summaryRecord?.request_type, props.summaryRecord?.requested_reasoning_effort, props.summaryRecord?.reasoning_effort, @@ -1298,6 +1302,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) { const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail) const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail) const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail) + const responseModel = resolveHeaderModelTextField('response_model', nextDetail) emit('requestState', { id, @@ -1334,6 +1339,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) { ...(reasoningEffort ? { reasoningEffort } : {}), ...(serviceTier ? { serviceTier } : {}), ...(actualServiceTier ? { actualServiceTier } : {}), + ...(responseModel ? { responseModel } : {}), errorMessage: nextDetail.error_message ?? undefined, updatedAt: nextDetail.updated_at ?? undefined, }) @@ -1610,7 +1616,7 @@ const headerModelRecord = computed(() => { return { model: resolveHeaderModelTextField('model', currentDetail) ?? '-', target_model: resolveHeaderModelTextField('target_model', currentDetail), - model_version: resolveHeaderModelTextField('model_version', currentDetail), + response_model: resolveHeaderModelTextField('response_model', currentDetail), request_type: resolveHeaderModelTextField('request_type', currentDetail), requested_reasoning_effort: resolveHeaderModelTextField( 'requested_reasoning_effort', diff --git a/frontend/src/features/usage/components/UsageModelDisplay.vue b/frontend/src/features/usage/components/UsageModelDisplay.vue index 123d3094f..7f6bf2f25 100644 --- a/frontend/src/features/usage/components/UsageModelDisplay.vue +++ b/frontend/src/features/usage/components/UsageModelDisplay.vue @@ -9,18 +9,29 @@ >
{{ record.model }} -