feat: expose upstream response model in usage records

This commit is contained in:
AAEE86
2026-09-21 09:51:33 +08:00
parent ba7c9f8b27
commit f960bbd2c8
24 changed files with 466 additions and 83 deletions
@@ -567,6 +567,7 @@ fn build_users_me_usage_record_payload(
"id": item.id, "id": item.id,
"model": item.model, "model": item.model,
"target_model": serde_json::Value::Null, "target_model": serde_json::Value::Null,
"response_model": item.provider_response_model(),
"api_format": item.api_format, "api_format": item.api_format,
"endpoint_api_format": item.endpoint_api_format, "endpoint_api_format": item.endpoint_api_format,
"has_format_conversion": item.has_format_conversion, "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"), "client_ip": users_me_usage_metadata_string(item, "client_ip"),
"user_agent": users_me_usage_metadata_string(item, "user_agent"), "user_agent": users_me_usage_metadata_string(item, "user_agent"),
"target_model": item.target_model, "target_model": item.target_model,
"response_model": item.provider_response_model(),
"has_fallback": item.has_fallback(), "has_fallback": item.has_fallback(),
}); });
payload["end_to_end_time_ms"] = json!(users_me_usage_metadata_u64(item, "end_to_end_time_ms")); 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); 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] #[test]
fn user_usage_payloads_project_end_to_end_timings_from_metadata() { fn user_usage_payloads_project_end_to_end_timings_from_metadata() {
let item = StoredRequestUsageAudit { let item = StoredRequestUsageAudit {
@@ -1349,6 +1349,7 @@ fn admin_usage_active_request_json(
if let Some(target_model) = item.target_model.as_ref() { if let Some(target_model) = item.target_model.as_ref() {
value["target_model"] = json!(target_model); value["target_model"] = json!(target_model);
} }
value["response_model"] = json!(item.provider_response_model());
if let Some(reasoning_effort) = item.provider_reasoning_effort() { if let Some(reasoning_effort) = item.provider_reasoning_effort() {
value["reasoning_effort"] = json!(reasoning_effort); value["reasoning_effort"] = json!(reasoning_effort);
} }
@@ -1408,6 +1409,7 @@ pub fn admin_usage_record_json(
"provider": item.provider_name, "provider": item.provider_name,
"model": item.model, "model": item.model,
"target_model": item.target_model, "target_model": item.target_model,
"response_model": item.provider_response_model(),
"input_tokens": item.input_tokens, "input_tokens": item.input_tokens,
"effective_input_tokens": admin_usage_effective_input_tokens(item), "effective_input_tokens": admin_usage_effective_input_tokens(item),
"output_tokens": item.output_tokens, "output_tokens": item.output_tokens,
@@ -2842,6 +2844,32 @@ mod tests {
assert_eq!(record["client_is_stream"], false); 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] #[test]
fn admin_usage_payloads_project_end_to_end_timings_from_metadata() { fn admin_usage_payloads_project_end_to_end_timings_from_metadata() {
let item = StoredRequestUsageAudit { let item = StoredRequestUsageAudit {
@@ -183,6 +183,7 @@ SELECT
OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL 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_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_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->>'client_requested_stream') IN ('true', 'false')
OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false') OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false')
OR ("usage".request_metadata->>'websocket_mode') 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'), ''), NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), ''),
'provider_actual_service_tier', 'provider_actual_service_tier',
NULLIF(BTRIM("usage".request_metadata->>'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', 'client_requested_stream',
CASE CASE
WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false')
@@ -183,6 +183,7 @@ SELECT
OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL 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_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_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->>'client_requested_stream') IN ('true', 'false')
OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false') OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false')
OR ("usage".request_metadata->>'websocket_mode') 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'), ''), NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), ''),
'provider_actual_service_tier', 'provider_actual_service_tier',
NULLIF(BTRIM("usage".request_metadata->>'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', 'client_requested_stream',
CASE CASE
WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false')
@@ -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_reasoning_effort'"));
assert!(sql.contains("request_metadata->>'provider_service_tier'")); assert!(sql.contains("request_metadata->>'provider_service_tier'"));
assert!(sql.contains("request_metadata->>'provider_actual_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("request_metadata->>'websocket_mode'"));
assert!(sql.contains("'websocket_mode'")); assert!(sql.contains("'websocket_mode'"));
assert!(sql.contains("AS client_family")); assert!(sql.contains("AS client_family"));
@@ -8,9 +8,10 @@ use serde_json::{Map, Value};
use crate::repository::candidates::sanitize_request_candidate_skip_reason; use crate::repository::candidates::sanitize_request_candidate_skip_reason;
use super::{ use super::{
LIVE_SESSION_METADATA_KEY, PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, normalize_provider_response_model, LIVE_SESSION_METADATA_KEY,
PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY,
PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_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, REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY,
ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY,
USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY,
@@ -94,6 +95,12 @@ pub fn sanitize_usage_request_metadata_object(source: &Map<String, Value>) -> Op
] { ] {
insert_known_string(source, &mut target, key, sanitize_service_tier); 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( insert_bounded_u64(
source, source,
&mut target, &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] #[test]
fn persistence_projection_keeps_only_bounded_settlement_facts() { fn persistence_projection_keeps_only_bounded_settlement_facts() {
let metadata = sanitize_usage_request_metadata(Some(json!({ let metadata = sanitize_usage_request_metadata(Some(json!({
@@ -15,11 +15,12 @@ pub use policy::*;
pub use types::{ pub use types::{
canonical_usage_body_ref_for, extract_provider_actual_service_tier_from_response, 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_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, resolve_provider_cache_ttl_minutes, resolve_provider_service_tier_from_request_capture,
usage_body_ref, usage_request_metadata_client_family, ApiKeyLastUsedDelta, usage_body_capture_is_authoritative, usage_body_ref, usage_request_metadata_client_family,
ManagementTokenCounterDelta, PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest, ApiKeyLastUsedDelta, ManagementTokenCounterDelta, PendingUsageCleanupSummary,
ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary, ProviderApiKeyWindowUsageRequest, ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary,
StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow, StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary, StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary,
StoredUsageBodyPayload, StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary, StoredUsageBodyPayload, StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
@@ -46,9 +47,9 @@ pub use types::{
UsageTimeSeriesQuery, UsageWriteRepository, LIVE_SESSION_METADATA_KEY, UsageTimeSeriesQuery, UsageWriteRepository, LIVE_SESSION_METADATA_KEY,
PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_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_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY,
PROVIDER_SERVICE_TIER_METADATA_KEY, REALTIME_SESSION_METADATA_KEY, PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
REQUESTED_REASONING_EFFORT_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY,
ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY,
USAGE_PRICING_AVAILABLE_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY,
WEBSOCKET_TRANSPORT_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_METADATA_KEY,
}; };
@@ -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 REQUESTED_REASONING_EFFORT_METADATA_KEY: &str = "requested_reasoning_effort";
pub const PROVIDER_SERVICE_TIER_METADATA_KEY: &str = "provider_service_tier"; 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_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 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_CANDIDATE_SKIP_REASON_METADATA_KEY: &str = "routing_candidate_skip_reason";
pub const ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY: &str = "routing_failure_diagnostic"; pub const ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY: &str = "routing_failure_diagnostic";
@@ -119,6 +120,42 @@ pub fn normalize_provider_service_tier(value: &str) -> Option<String> {
Some(value.to_ascii_lowercase()) Some(value.to_ascii_lowercase())
} }
/// 清洗响应体顶层 `model`,保留大小写,只去除首尾空白。
pub fn normalize_provider_response_model(value: &str) -> Option<String> {
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<UsageBodyCaptureState>,
response_body: Option<&Value>,
response_body_state: Option<UsageBodyCaptureState>,
) -> Option<String> {
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. /// 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 /// 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<UsageBodyCaptureState>, provider_request_body_state: Option<UsageBodyCaptureState>,
request_metadata: Option<&Value>, request_metadata: Option<&Value>,
) -> Option<String> { ) -> Option<String> {
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); 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) .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: Option<&Value>,
request_body_state: Option<UsageBodyCaptureState>, request_body_state: Option<UsageBodyCaptureState>,
) -> bool { ) -> bool {
@@ -184,7 +221,7 @@ fn resolve_reasoning_effort_from_request_capture(
request_metadata: Option<&Value>, request_metadata: Option<&Value>,
metadata_key: &str, metadata_key: &str,
) -> Option<String> { ) -> Option<String> {
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); return extract_provider_reasoning_effort_from_body(request_body);
} }
@@ -701,6 +738,11 @@ impl StoredRequestUsageAudit {
}) })
} }
pub fn provider_response_model(&self) -> Option<String> {
self.request_metadata_string(PROVIDER_RESPONSE_MODEL_METADATA_KEY)
.and_then(normalize_provider_response_model)
}
pub fn provider_cache_ttl_minutes(&self) -> Option<i64> { pub fn provider_cache_ttl_minutes(&self) -> Option<i64> {
resolve_provider_cache_ttl_minutes( resolve_provider_cache_ttl_minutes(
self.endpoint_api_format self.endpoint_api_format
@@ -2646,7 +2688,8 @@ fn parse_timestamp(value: i64, field_name: &str) -> Result<u64, crate::DataLayer
mod tests { mod tests {
use super::{ use super::{
canonical_usage_body_ref_for, extract_provider_actual_service_tier_from_response, canonical_usage_body_ref_for, extract_provider_actual_service_tier_from_response,
extract_provider_service_tier_from_body, normalize_provider_reasoning_effort, extract_provider_response_model_from_bodies, extract_provider_service_tier_from_body,
normalize_provider_reasoning_effort, normalize_provider_response_model,
normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, usage_body_ref, normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, usage_body_ref,
StoredRequestUsageAudit, UpsertUsageRecord, UsageBodyCaptureState, UsageBodyCaptureStorage, StoredRequestUsageAudit, UpsertUsageRecord, UsageBodyCaptureState, UsageBodyCaptureStorage,
UsageBodyField, UsageProviderPerformanceQuery, REALTIME_SESSION_METADATA_KEY, UsageBodyField, UsageProviderPerformanceQuery, REALTIME_SESSION_METADATA_KEY,
@@ -2672,6 +2715,58 @@ mod tests {
} }
} }
#[test]
fn response_model_requires_authoritative_different_top_level_models() {
let request = json!({"model": " gpt-5 "});
let response = json!({"model": " gpt-5.1 "});
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Inline),
Some(&response),
Some(UsageBodyCaptureState::Inline),
),
Some("gpt-5.1".to_string())
);
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Inline),
Some(&json!({"model": "gpt-5"})),
Some(UsageBodyCaptureState::Inline),
),
None
);
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Truncated),
Some(&response),
Some(UsageBodyCaptureState::Inline),
),
None
);
}
#[test]
fn response_model_keeps_case_and_rejects_invalid_values() {
assert_eq!(
normalize_provider_response_model(" GPT-5.1 "),
Some("GPT-5.1".to_string())
);
assert_eq!(normalize_provider_response_model(" "), None);
assert_eq!(normalize_provider_response_model(&"x".repeat(257)), None);
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&json!({"model": "gpt-5"})),
None,
Some(&json!({"model": 42})),
None,
),
None
);
}
#[test] #[test]
fn provider_fact_normalization_preserves_non_ascii_case_and_byte_count() { fn provider_fact_normalization_preserves_non_ascii_case_and_byte_count() {
for normalize in [ for normalize in [
+10 -3
View File
@@ -15,9 +15,9 @@ use super::{BorrowedUsageEventEnvelope, UsageEvent, UsageEventData, USAGE_EVENT_
use crate::body_capture::mark_usage_event_capture_truncated; use crate::body_capture::mark_usage_event_capture_truncated;
use crate::request_metadata::{ use crate::request_metadata::{
attach_client_request_body_metadata, attach_provider_request_body_metadata, attach_client_request_body_metadata, attach_provider_request_body_metadata,
attach_provider_response_body_metadata, clear_client_request_body_metadata, attach_provider_response_body_metadata, attach_provider_response_model_metadata,
clear_provider_request_body_metadata, request_body_derived_facts_action, clear_client_request_body_metadata, clear_provider_request_body_metadata,
RequestBodyDerivedFactsAction, request_body_derived_facts_action, RequestBodyDerivedFactsAction,
}; };
const DIAGNOSTIC_FIELDS: [&str; 8] = [ const DIAGNOSTIC_FIELDS: [&str; 8] = [
@@ -238,6 +238,13 @@ impl WireOverrides {
RequestBodyDerivedFactsAction::Clear | RequestBodyDerivedFactsAction::Preserve => {} RequestBodyDerivedFactsAction::Clear | RequestBodyDerivedFactsAction::Preserve => {}
} }
metadata = attach_provider_response_body_metadata(metadata, data.response_body.as_ref()); 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. // Billing reads raw-body TTL before metadata regardless of capture state.
// Preserve that precedence independently of reasoning and tier authority. // Preserve that precedence independently of reasoning and tier authority.
if let Some(cache_ttl) = body_cache_ttl { if let Some(cache_ttl) = body_cache_ttl {
+11 -3
View File
@@ -5,9 +5,9 @@ use aether_data_contracts::DataLayerError;
use crate::request_metadata::{ use crate::request_metadata::{
attach_client_request_body_metadata, attach_provider_request_body_metadata, attach_client_request_body_metadata, attach_provider_request_body_metadata,
clear_client_request_body_metadata, clear_provider_request_body_metadata, attach_provider_response_model_metadata, clear_client_request_body_metadata,
request_body_derived_facts_action, sanitize_usage_request_metadata, clear_provider_request_body_metadata, request_body_derived_facts_action,
RequestBodyDerivedFactsAction, sanitize_usage_request_metadata, RequestBodyDerivedFactsAction,
}; };
use crate::{UsageEvent, UsageEventType}; use crate::{UsageEvent, UsageEventType};
@@ -85,6 +85,14 @@ pub fn build_upsert_usage_record_from_event(
} }
RequestBodyDerivedFactsAction::Preserve => {} 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; let now_unix_secs = event.timestamp_ms / 1_000;
Ok(UpsertUsageRecord { Ok(UpsertUsageRecord {
@@ -1,13 +1,15 @@
use aether_contracts::ExecutionPlan; use aether_contracts::ExecutionPlan;
use aether_data_contracts::repository::usage::{ use aether_data_contracts::repository::usage::{
extract_provider_actual_service_tier_from_response, extract_provider_actual_service_tier_from_response,
extract_provider_reasoning_effort_from_body, extract_provider_service_tier_from_body, extract_provider_reasoning_effort_from_body, extract_provider_response_model_from_bodies,
normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, 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 as project_usage_request_metadata,
sanitize_usage_request_metadata_object as project_usage_request_metadata_object, sanitize_usage_request_metadata_object as project_usage_request_metadata_object,
sanitize_usage_request_metadata_ref as project_usage_request_metadata_ref, sanitize_usage_request_metadata_ref as project_usage_request_metadata_ref,
UsageBodyCaptureState, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, usage_body_capture_is_authoritative, UsageBodyCaptureState,
PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_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, REQUESTED_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY,
}; };
use serde_json::{Map, Value}; 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()) attach_provider_actual_service_tier_metadata(metadata, actual_service_tier.as_deref())
} }
pub(crate) fn attach_provider_response_model_metadata(
metadata: Option<Value>,
request_body: Option<&Value>,
request_body_state: Option<UsageBodyCaptureState>,
response_body: Option<&Value>,
response_body_state: Option<UsageBodyCaptureState>,
) -> Option<Value> {
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<Value>,
request_body: Option<&Value>,
request_body_state: Option<UsageBodyCaptureState>,
response_body: Option<&Value>,
response_body_state: Option<UsageBodyCaptureState>,
) -> Option<Value> {
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 /// 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 /// 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 /// placeholders/absent bodies are not authoritative, so a terminal summary already present in
@@ -323,8 +394,9 @@ mod tests {
use super::{ use super::{
attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata, attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata,
attach_provider_request_body_metadata, attach_provider_response_body_metadata, attach_provider_request_body_metadata, attach_provider_response_body_metadata,
build_usage_request_metadata_seed, merge_usage_request_metadata, attach_provider_response_model_metadata, build_usage_request_metadata_seed,
merge_usage_request_metadata_owned, refresh_provider_response_body_metadata, 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, retain_first_byte_request_metadata, sanitize_usage_request_metadata,
sanitize_usage_request_metadata_ref, 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] #[test]
fn terminal_response_refresh_replaces_stale_actual_tier() { fn terminal_response_refresh_replaces_stale_actual_tier() {
let metadata = refresh_provider_response_body_metadata( let metadata = refresh_provider_response_body_metadata(
+12 -4
View File
@@ -21,9 +21,10 @@ use crate::executor::spawn_on_usage_background_runtime;
use crate::queue::is_permanent_enqueue_error; use crate::queue::is_permanent_enqueue_error;
use crate::request_metadata::{ use crate::request_metadata::{
attach_client_request_body_metadata, attach_provider_request_body_metadata, attach_client_request_body_metadata, attach_provider_request_body_metadata,
attach_provider_response_body_metadata, clear_client_request_body_metadata, attach_provider_response_body_metadata, attach_provider_response_model_metadata,
clear_provider_request_body_metadata, request_body_derived_facts_action, clear_client_request_body_metadata, clear_provider_request_body_metadata,
retain_first_byte_request_metadata, RequestBodyDerivedFactsAction, request_body_derived_facts_action, retain_first_byte_request_metadata,
RequestBodyDerivedFactsAction,
}; };
use crate::settlement::{ use crate::settlement::{
reconcile_usage_policy_cost_for_event_with_result, settle_usage_with_reconciled_cost, 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) { fn preserve_provider_response_facts(event: &mut UsageEvent) {
let metadata = event.data.request_metadata.take(); 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()); 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 { impl UsageQueueHealthSnapshot {
+25 -3
View File
@@ -17,9 +17,10 @@ use crate::body_capture::{
}; };
use crate::request_metadata::{ use crate::request_metadata::{
attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata, attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata,
attach_provider_request_body_metadata, build_usage_request_metadata_seed, attach_provider_request_body_metadata, attach_provider_response_model_metadata,
merge_usage_request_metadata, merge_usage_request_metadata_owned, build_usage_request_metadata_seed, merge_usage_request_metadata,
refresh_provider_response_body_metadata, sanitize_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, sanitize_usage_request_metadata_ref,
}; };
use crate::{ use crate::{
@@ -724,6 +725,13 @@ fn build_terminal_usage_event_from_seed_impl(
Some(model.as_str()), Some(model.as_str()),
provider_request.as_ref(), 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 { let mut data = UsageEventData {
user_id, user_id,
@@ -1047,6 +1055,13 @@ pub fn build_sync_terminal_usage_seed(
context_seed.request_metadata, context_seed.request_metadata,
provider_response_full.as_ref(), 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 { TerminalUsageSeed {
terminal_state, terminal_state,
@@ -1225,6 +1240,13 @@ pub fn build_stream_terminal_usage_seed(
context_seed.request_metadata, context_seed.request_metadata,
provider_response_full.as_ref(), 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 // 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. // body and summary disagree; attach it after the body refresh so it wins.
let request_metadata = attach_provider_actual_service_tier_metadata( let request_metadata = attach_provider_actual_service_tier_metadata(
+1
View File
@@ -219,6 +219,7 @@ export interface RequestDetail {
has_format_conversion?: boolean | null has_format_conversion?: boolean | null
model: string model: string
target_model?: string | null // 映射后的目标模型名 target_model?: string | null // 映射后的目标模型名
response_model?: string | null // 上游响应体实际返回的模型名
requested_reasoning_effort?: string | null requested_reasoning_effort?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
service_tier?: string | null service_tier?: string | null
+3
View File
@@ -60,6 +60,7 @@ export interface UsageRecordDetail {
reasoning_effort?: string | null reasoning_effort?: string | null
service_tier?: string | null service_tier?: string | null
actual_service_tier?: string | null actual_service_tier?: string | null
response_model?: string | null
input_tokens: number input_tokens: number
effective_input_tokens?: number effective_input_tokens?: number
output_tokens: number output_tokens: number
@@ -392,6 +393,7 @@ export const meApi = {
has_format_conversion?: boolean | null has_format_conversion?: boolean | null
has_fallback?: boolean | null has_fallback?: boolean | null
target_model?: string | null target_model?: string | null
response_model?: string | null
request_type?: string | null request_type?: string | null
requested_reasoning_effort?: string | null requested_reasoning_effort?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
@@ -438,6 +440,7 @@ export const meApi = {
has_format_conversion?: boolean | null has_format_conversion?: boolean | null
has_fallback?: boolean | null has_fallback?: boolean | null
target_model?: string | null target_model?: string | null
response_model?: string | null
request_type?: string | null request_type?: string | null
requested_reasoning_effort?: string | null requested_reasoning_effort?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
+3
View File
@@ -20,6 +20,7 @@ export interface UsageRecord {
reasoning_effort?: string | null reasoning_effort?: string | null
service_tier?: string | null service_tier?: string | null
actual_service_tier?: string | null actual_service_tier?: string | null
response_model?: string | null
input_tokens: number input_tokens: number
effective_input_tokens?: number effective_input_tokens?: number
output_tokens: number output_tokens: number
@@ -618,6 +619,7 @@ export const usageApi = {
has_format_conversion?: boolean | null has_format_conversion?: boolean | null
has_fallback?: boolean | null has_fallback?: boolean | null
target_model?: string | null target_model?: string | null
response_model?: string | null
request_type?: string | null request_type?: string | null
requested_reasoning_effort?: string | null requested_reasoning_effort?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
@@ -685,6 +687,7 @@ export const usageApi = {
has_format_conversion?: boolean | null has_format_conversion?: boolean | null
has_fallback?: boolean | null has_fallback?: boolean | null
target_model?: string | null target_model?: string | null
response_model?: string | null
request_type?: string | null request_type?: string | null
requested_reasoning_effort?: string | null requested_reasoning_effort?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
+1
View File
@@ -19,6 +19,7 @@ export interface UsageRecord {
model: string model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空) target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段) model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
response_model?: string | null // 上游响应体实际返回的模型名
request_type?: string | null // 由请求语义识别出的操作类型 request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系 requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别 reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
@@ -1016,6 +1016,7 @@ const emit = defineEmits<{
reasoningEffort?: string | null reasoningEffort?: string | null
serviceTier?: string | null serviceTier?: string | null
actualServiceTier?: string | null actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null imageProgress?: ImageProgress | null
errorMessage?: string | null errorMessage?: string | null
updatedAt?: string | null updatedAt?: string | null
@@ -1158,6 +1159,7 @@ type HeaderModelTextField =
| 'model' | 'model'
| 'target_model' | 'target_model'
| 'model_version' | 'model_version'
| 'response_model'
| 'request_type' | 'request_type'
| 'requested_reasoning_effort' | 'requested_reasoning_effort'
| 'reasoning_effort' | 'reasoning_effort'
@@ -1166,6 +1168,7 @@ type HeaderModelTextField =
const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([ const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([
'target_model', 'target_model',
'response_model',
'reasoning_effort', 'reasoning_effort',
'service_tier', 'service_tier',
'actual_service_tier', 'actual_service_tier',
@@ -1252,6 +1255,7 @@ watch(
props.summaryRecord?.model, props.summaryRecord?.model,
props.summaryRecord?.target_model, props.summaryRecord?.target_model,
props.summaryRecord?.model_version, props.summaryRecord?.model_version,
props.summaryRecord?.response_model,
props.summaryRecord?.request_type, props.summaryRecord?.request_type,
props.summaryRecord?.requested_reasoning_effort, props.summaryRecord?.requested_reasoning_effort,
props.summaryRecord?.reasoning_effort, props.summaryRecord?.reasoning_effort,
@@ -1298,6 +1302,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail) const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail)
const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail) const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail)
const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail) const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail)
const responseModel = resolveHeaderModelTextField('response_model', nextDetail)
emit('requestState', { emit('requestState', {
id, id,
@@ -1334,6 +1339,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
...(reasoningEffort ? { reasoningEffort } : {}), ...(reasoningEffort ? { reasoningEffort } : {}),
...(serviceTier ? { serviceTier } : {}), ...(serviceTier ? { serviceTier } : {}),
...(actualServiceTier ? { actualServiceTier } : {}), ...(actualServiceTier ? { actualServiceTier } : {}),
...(responseModel ? { responseModel } : {}),
errorMessage: nextDetail.error_message ?? undefined, errorMessage: nextDetail.error_message ?? undefined,
updatedAt: nextDetail.updated_at ?? undefined, updatedAt: nextDetail.updated_at ?? undefined,
}) })
@@ -1610,7 +1616,7 @@ const headerModelRecord = computed(() => {
return { return {
model: resolveHeaderModelTextField('model', currentDetail) ?? '-', model: resolveHeaderModelTextField('model', currentDetail) ?? '-',
target_model: resolveHeaderModelTextField('target_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), request_type: resolveHeaderModelTextField('request_type', currentDetail),
requested_reasoning_effort: resolveHeaderModelTextField( requested_reasoning_effort: resolveHeaderModelTextField(
'requested_reasoning_effort', 'requested_reasoning_effort',
@@ -9,18 +9,29 @@
> >
<div <div
class="flex min-w-0 max-w-full items-center gap-1" class="flex min-w-0 max-w-full items-center gap-1"
:class="[modelRowClass, actualModel ? 'flex-wrap' : '']" :class="[modelRowClass, hasModelFacts ? 'flex-wrap' : '']"
> >
<span <span
class="min-w-0 truncate" class="min-w-0 truncate"
:class="modelClass" :class="modelClass"
data-usage-model-source data-usage-model-source
>{{ record.model }}</span> >{{ record.model }}</span>
<template v-if="actualModel"> <template v-if="hasModelFacts">
<span <div
class="order-last basis-full min-w-0 break-all whitespace-normal text-muted-foreground" class="order-last basis-full flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-muted-foreground"
data-usage-model-target data-usage-model-facts
><span class="mr-1">-&gt;</span>{{ actualModel }}</span> >
<span
v-if="mappingModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-mapping
><span class="mr-1 text-[10px] text-muted-foreground/70">映射模型</span>{{ mappingModel }}</span>
<span
v-if="responseModel"
class="min-w-0 break-all whitespace-normal"
data-usage-model-response
><span class="mr-1 text-[10px] text-muted-foreground/70">响应模型</span>{{ responseModel }}</span>
</div>
</template> </template>
<template v-if="!shouldStackBadges"> <template v-if="!shouldStackBadges">
<Badge <Badge
@@ -83,7 +94,7 @@ interface ModelBadgePresentation {
interface UsageModelDisplayRecord { interface UsageModelDisplayRecord {
model: string model: string
target_model?: string | null target_model?: string | null
model_version?: string | null response_model?: string | null
request_type?: string | null request_type?: string | null
requested_reasoning_effort?: string | null requested_reasoning_effort?: string | null
reasoning_effort?: string | null reasoning_effort?: string | null
@@ -113,15 +124,20 @@ const props = withDefaults(defineProps<{
showReasoningBadge: true, showReasoningBadge: true,
}) })
const actualModel = computed(() => { const mappingModel = computed(() => {
const targetModel = normalizeText(props.record.target_model) const targetModel = normalizeText(props.record.target_model)
if (targetModel && targetModel !== props.record.model) return targetModel if (targetModel && targetModel !== normalizeText(props.record.model)) return targetModel
const modelVersion = normalizeText(props.record.model_version)
if (modelVersion && modelVersion !== props.record.model) return modelVersion
return null return null
}) })
const responseModel = computed(() => {
const response = normalizeText(props.record.response_model)
if (response && response !== normalizeText(props.record.model)) return response
return null
})
const hasModelFacts = computed(() => mappingModel.value !== null || responseModel.value !== null)
const reasoningLabel = computed(() => { const reasoningLabel = computed(() => {
const requested = normalizeText(props.record.requested_reasoning_effort) const requested = normalizeText(props.record.requested_reasoning_effort)
const actual = normalizeText(props.record.reasoning_effort) const actual = normalizeText(props.record.reasoning_effort)
@@ -189,7 +205,7 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
}) })
const shouldStackBadges = computed(() => ( const shouldStackBadges = computed(() => (
actualModel.value === null && modelBadges.value.length >= 3 !hasModelFacts.value && modelBadges.value.length >= 3
)) ))
function normalizeText(value: string | null | undefined): string | null { function normalizeText(value: string | null | undefined): string | null {
@@ -1620,20 +1620,6 @@ function getApiFormatTooltip(record: UsageRecord): string {
return displayFormat return displayFormat
} }
// 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version)
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
function getActualModel(record: UsageRecord): string | null {
// 优先显示模型映射
if (record.target_model && record.target_model !== record.model) {
return record.target_model
}
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
if (record.model_version && record.model_version !== record.model) {
return record.model_version
}
return null
}
function getReasoningEffort(record: UsageRecord): string | null { function getReasoningEffort(record: UsageRecord): string | null {
const requested = record.requested_reasoning_effort?.trim() const requested = record.requested_reasoning_effort?.trim()
const actual = record.reasoning_effort?.trim() const actual = record.reasoning_effort?.trim()
@@ -1708,15 +1694,18 @@ function getServiceTierTitle(record: UsageRecord): string {
// 获取模型列的 tooltip // 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string { function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record) const reasoningEffort = getReasoningEffort(record)
const serviceTierTitle = getServiceTierTitle(record) const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : '' const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : '' const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}` const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
if (actualModel) { const requestModel = record.model.trim()
return `${record.model} -> ${actualModel}${suffix}` const mappingModel = record.target_model?.trim()
} const responseModel = record.response_model?.trim()
return `${record.model}${suffix}` return [
requestModel,
mappingModel && mappingModel !== requestModel ? `映射模型: ${mappingModel}` : null,
responseModel && responseModel !== requestModel ? `响应模型: ${responseModel}` : null,
].filter((line): line is string => Boolean(line)).join('\n') + suffix
} }
</script> </script>
@@ -382,8 +382,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
expect(modelRow?.textContent).toContain('gpt-5') expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->') expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1') expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full')) expect(modelRow?.querySelector('[data-usage-model-mapping]')).not.toBeNull()
.toBe(true)
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent) expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max') .toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent) expect(modelRow?.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
@@ -460,7 +459,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => { await vi.waitFor(() => {
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent) expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber') .toContain('Cyber')
expect(document.body.querySelector('[data-usage-model-target]')?.textContent) expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1') .toContain('gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent) expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max') .toContain('xhigh -> max')
@@ -571,8 +570,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => { await vi.waitFor(() => {
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1) expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim()) expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent?.trim())
.toBe('->gpt-5.1') .toBe('映射模型gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim()) expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh -> max') .toBe('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull() expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -583,7 +582,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
}) })
}) })
it('uses detail model_version when the lightweight summary has null model facts', async () => { it('does not use legacy model_version as a response-model fallback', async () => {
apiMocks.getRequestDetail.mockResolvedValue({ apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(), ...buildEmbeddingDetail(),
id: 'usage-version-summary', id: 'usage-version-summary',
@@ -626,8 +625,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await nextTick() await nextTick()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim()) expect(document.body.querySelector('[data-usage-model-response]')).toBeNull()
.toBe('->gpt-5.1-2026-07-17') expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
}) })
}) })
@@ -687,7 +686,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
} }
await nextTick() await nextTick()
expect(document.body.querySelector('[data-usage-model-target]')).toBeNull() expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim()) expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh') .toBe('xhigh')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull() expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -460,10 +460,11 @@ describe('UsageRecordsTable', () => {
.toBe('会话压缩') .toBe('会话压缩')
}) })
it('shows mapping, reasoning, Fast, and Cyber in the model area', () => { it('shows mapping and response models as separately labelled facts', () => {
const root = mountUsageRecordsTable([buildRecord({ const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5', model: 'gpt-5',
target_model: 'gpt-5.1', target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh', requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max', reasoning_effort: 'max',
service_tier: 'priority', service_tier: 'priority',
@@ -476,6 +477,8 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('gpt-5') expect(root.textContent).toContain('gpt-5')
expect(root.textContent).toContain('gpt-5.1') expect(root.textContent).toContain('gpt-5.1')
expect(root.textContent).toContain('映射模型')
expect(root.textContent).toContain('响应模型')
expect(root.textContent).toContain('xhigh -> max') expect(root.textContent).toContain('xhigh -> max')
expect(root.textContent).toContain('Fast') expect(root.textContent).toContain('Fast')
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]') const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
@@ -509,15 +512,35 @@ describe('UsageRecordsTable', () => {
expect(inlineLayout).not.toBeNull() expect(inlineLayout).not.toBeNull()
const modelRow = inlineLayout?.firstElementChild const modelRow = inlineLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5') expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->') expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent).toContain('映射模型')
expect(modelRow?.textContent).toContain('gpt-5.1') expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('order-last')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent).toContain('xhigh -> max') expect(modelRow?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent).toContain('xhigh -> max')
expect(modelRow?.querySelector('[data-usage-model-badge="fast"]')?.textContent).toContain('Fast') expect(modelRow?.querySelector('[data-usage-model-badge="fast"]')?.textContent).toContain('Fast')
expect(modelRow?.querySelector('[data-usage-model-badge="cyber"]')?.textContent).toContain('Cyber') expect(modelRow?.querySelector('[data-usage-model-badge="cyber"]')?.textContent).toContain('Cyber')
}) })
it('shows a response model without inventing a mapping arrow or using model_version', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5.1',
model_version: 'legacy-version',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-mapping]')).toBeNull()
expect(root.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
expect(root.textContent).not.toContain('legacy-version')
expect(root.textContent).not.toContain('->')
})
it('hides a response model when it matches the request model', () => {
const root = mountUsageRecordsTable([buildRecord({
response_model: 'gpt-5',
target_model: null,
})])
expect(root.querySelector('[data-usage-model-response]')).toBeNull()
})
it('stacks three model badges even without a model mapping', () => { it('stacks three model badges even without a model mapping', () => {
const root = mountUsageRecordsTable([buildRecord({ const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5', model: 'gpt-5',
@@ -716,7 +716,13 @@ export function useUsageData(options: UseUsageDataOptions) {
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim() ? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
? record.actual_service_tier ? record.actual_service_tier
: null) : null)
: existing.actual_service_tier : existing.actual_service_tier,
// 终态列表快照是最终候选的权威事实;空值必须清除旧候选的响应模型。
response_model: statusProgressed
? (typeof record.response_model === 'string' && record.response_model.trim()
? record.response_model
: null)
: existing.response_model
} }
}) })
} }
+10
View File
@@ -649,6 +649,10 @@ async function pollActiveRequests() {
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim() record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
? update.actual_service_tier ? update.actual_service_tier
: null : null
// 活跃接口返回的是当前最终候选快照,服务端空值要清除旧响应模型。
record.response_model = typeof update.response_model === 'string' && update.response_model.trim()
? update.response_model
: null
// 管理员接口返回额外字段 // 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值 // 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string') { if ('provider' in update && typeof update.provider === 'string') {
@@ -1148,6 +1152,7 @@ function handleDetailRequestState(update: {
reasoningEffort?: string | null reasoningEffort?: string | null
serviceTier?: string | null serviceTier?: string | null
actualServiceTier?: string | null actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null imageProgress?: ImageProgress | null
errorMessage?: string | null errorMessage?: string | null
updatedAt?: string | null updatedAt?: string | null
@@ -1290,6 +1295,11 @@ function handleDetailRequestState(update: {
? update.actualServiceTier ? update.actualServiceTier
: null : null
} }
if ('responseModel' in update) {
record.response_model = typeof update.responseModel === 'string'
? update.responseModel
: null
}
if ('imageProgress' in update) { if ('imageProgress' in update) {
const nextProgress = update.imageProgress ?? null const nextProgress = update.imageProgress ?? null
if (!sameImageProgress(record.image_progress, nextProgress)) { if (!sameImageProgress(record.image_progress, nextProgress)) {