merge: integrate usage response models with service tier badges

Merge upstream PR #837, preserving its original commits and resolving the UsageModelDisplay layout conflict after #841. Cover response models alongside dynamic service-tier badges in detail tests.
This commit is contained in:
ZheFox
2026-09-22 23:31:22 +08:00
24 changed files with 666 additions and 85 deletions
@@ -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 {
@@ -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);
}
@@ -1445,6 +1446,11 @@ pub fn admin_usage_record_json(
let object = payload
.as_object_mut()
.expect("admin usage record payload should be an object");
// 大型 json! 宏接近 Rust 的递归展开上限,响应模型在宏展开后补入即可避免编译失败。
object.insert(
"response_model".to_string(),
json!(item.provider_response_model()),
);
object.insert(
"end_to_end_time_ms".to_string(),
json!(admin_usage_metadata_u64(item, "end_to_end_time_ms")),
@@ -2842,6 +2848,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 {
@@ -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')
@@ -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')
@@ -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"));
@@ -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<String, Value>) -> 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!({
@@ -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,
};
@@ -1,3 +1,4 @@
use aether_ai_formats::normalize_api_format_alias;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value;
@@ -6,6 +7,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 +121,141 @@ pub fn normalize_provider_service_tier(value: &str) -> Option<String> {
Some(value.to_ascii_lowercase())
}
/// 清洗模型名称,保留大小写,只去除首尾空白。
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())
}
fn extract_model_at_paths(value: &Value, paths: &[&[&str]]) -> Option<String> {
paths.iter().find_map(|path| {
let value = path
.iter()
.try_fold(value, |current, key| current.as_object()?.get(*key))?;
value.as_str().and_then(normalize_provider_response_model)
})
}
fn response_model_paths(provider_api_format: Option<&str>) -> &'static [&'static [&'static str]] {
match normalize_api_format_alias(provider_api_format.unwrap_or_default()).as_str() {
"gemini:generate_content" => {
// Gemini 原生响应使用 modelVersion;部分网关会改写为 model。
&[&["modelVersion"], &["model_version"], &["model"]]
}
"gemini:embedding" => {
// Gemini Embedding 可能返回 model、modelVersion 或 Vertex 的 deployedModelId。
&[
&["model"],
&["modelVersion"],
&["model_version"],
&["deployedModelId"],
&["deployed_model_id"],
]
}
"gemini:interactions" => {
// Interactions 请求既可能叫 model,也可能叫 agent;响应优先读取 model。
&[
&["model"],
&["modelVersion"],
&["model_version"],
&["agent"],
]
}
_ => &[&["model"]],
}
}
fn extract_model_from_known_response_wrappers(
response_body: &Value,
paths: &[&[&str]],
) -> Option<String> {
// 只展开协议中已知的 response/chunks 包装,避免在候选内容、工具参数等任意嵌套
// 对象中搜索同名字段,误把 role="model" 一类内容当成响应模型。
extract_model_at_paths(response_body, paths)
.or_else(|| {
response_body
.get("response")
.and_then(|response| extract_model_at_paths(response, paths))
})
.or_else(|| {
response_body
.get("chunks")
.and_then(Value::as_array)
.and_then(|chunks| {
chunks.iter().rev().find_map(|chunk| {
extract_model_at_paths(chunk, paths).or_else(|| {
chunk
.get("response")
.and_then(|response| extract_model_at_paths(response, paths))
})
})
})
})
.or_else(|| {
response_body
.get("response")
.and_then(|response| response.get("chunks"))
.and_then(Value::as_array)
.and_then(|chunks| {
chunks.iter().rev().find_map(|chunk| {
extract_model_at_paths(chunk, paths).or_else(|| {
chunk
.get("response")
.and_then(|response| extract_model_at_paths(response, paths))
})
})
})
})
}
fn extract_provider_model_from_response_body(
response_body: &Value,
provider_api_format: Option<&str>,
) -> Option<String> {
extract_model_from_known_response_wrappers(
response_body,
response_model_paths(provider_api_format),
)
}
fn extract_provider_model_from_request_body(
request_body: &Value,
request_api_format: Option<&str>,
) -> Option<String> {
let paths: &[&[&str]] =
match normalize_api_format_alias(request_api_format.unwrap_or_default()).as_str() {
"gemini:interactions" => &[&["model"], &["agent"]],
_ => &[&["model"]],
};
extract_model_at_paths(request_body, paths)
}
/// 只有请求体和响应体都可作为完整事实时,才计算响应模型,避免用截断内容猜测。
pub fn extract_provider_response_model_from_bodies(
request_body: Option<&Value>,
request_body_state: Option<UsageBodyCaptureState>,
request_api_format: Option<&str>,
response_body: Option<&Value>,
response_body_state: Option<UsageBodyCaptureState>,
provider_api_format: Option<&str>,
) -> 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 =
extract_provider_model_from_request_body(request_body?, request_api_format)?;
let response_model =
extract_provider_model_from_response_body(response_body?, provider_api_format)?;
(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 +266,7 @@ pub fn resolve_provider_service_tier_from_request_capture(
provider_request_body_state: Option<UsageBodyCaptureState>,
request_metadata: Option<&Value>,
) -> 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);
}
@@ -153,7 +290,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<UsageBodyCaptureState>,
) -> bool {
@@ -184,7 +321,7 @@ fn resolve_reasoning_effort_from_request_capture(
request_metadata: Option<&Value>,
metadata_key: &str,
) -> 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);
}
@@ -701,6 +838,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> {
resolve_provider_cache_ttl_minutes(
self.endpoint_api_format
@@ -2646,7 +2788,8 @@ fn parse_timestamp(value: i64, field_name: &str) -> Result<u64, crate::DataLayer
mod tests {
use super::{
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,
StoredRequestUsageAudit, UpsertUsageRecord, UsageBodyCaptureState, UsageBodyCaptureStorage,
UsageBodyField, UsageProviderPerformanceQuery, REALTIME_SESSION_METADATA_KEY,
@@ -2672,6 +2815,122 @@ 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("openai:chat"),
Some(&response),
Some(UsageBodyCaptureState::Inline),
Some("openai:chat"),
),
Some("gpt-5.1".to_string())
);
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Inline),
Some("openai:responses"),
Some(&json!({"model": "gpt-5"})),
Some(UsageBodyCaptureState::Inline),
Some("openai:responses"),
),
None
);
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Truncated),
Some("openai:chat"),
Some(&response),
Some(UsageBodyCaptureState::Inline),
Some("openai:chat"),
),
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("openai:chat"),
Some(&json!({"model": 42})),
None,
Some("openai:chat"),
),
None
);
}
#[test]
fn response_model_uses_provider_format_specific_nested_paths() {
let request = json!({"model": "gemini-2.5-flash"});
let response = json!({
"response": {
"modelVersion": "gemini-2.5-flash-001",
"candidates": [{"content": {"role": "model"}}]
}
});
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Inline),
Some("gemini:generate_content"),
Some(&response),
Some(UsageBodyCaptureState::Inline),
Some("gemini:generate_content"),
),
Some("gemini-2.5-flash-001".to_string())
);
let wrapped_chunks = json!({
"chunks": [
{"response": {"modelVersion": "gemini-old"}},
{"response": {"modelVersion": "gemini-final"}}
]
});
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Inline),
Some("gemini:generate_content"),
Some(&wrapped_chunks),
Some(UsageBodyCaptureState::Inline),
Some("gemini:generate_content"),
),
Some("gemini-final".to_string())
);
let ambiguous = json!({
"metadata": {"model": "do-not-use"},
"candidates": [{"content": {"role": "model"}}]
});
assert_eq!(
extract_provider_response_model_from_bodies(
Some(&request),
Some(UsageBodyCaptureState::Inline),
Some("gemini:generate_content"),
Some(&ambiguous),
Some(UsageBodyCaptureState::Inline),
Some("gemini:generate_content"),
),
None
);
}
#[test]
fn provider_fact_normalization_preserves_non_ascii_case_and_byte_count() {
for normalize in [
+12 -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::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,
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, RequestBodyDerivedFactsAction,
};
const DIAGNOSTIC_FIELDS: [&str; 8] = [
@@ -238,6 +238,15 @@ impl WireOverrides {
RequestBodyDerivedFactsAction::Clear | RequestBodyDerivedFactsAction::Preserve => {}
}
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.api_format.as_deref(),
data.response_body.as_ref(),
data.response_body_state,
data.endpoint_api_format.as_deref(),
);
// 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 {
+13 -3
View File
@@ -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,16 @@ 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.api_format.as_deref(),
data.response_body.as_ref(),
data.response_body_state,
data.endpoint_api_format.as_deref(),
);
let now_unix_secs = event.timestamp_ms / 1_000;
Ok(UpsertUsageRecord {
@@ -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,83 @@ 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<Value>,
request_body: Option<&Value>,
request_body_state: Option<UsageBodyCaptureState>,
request_api_format: Option<&str>,
response_body: Option<&Value>,
response_body_state: Option<UsageBodyCaptureState>,
provider_api_format: Option<&str>,
) -> 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,
request_api_format,
response_body,
response_body_state,
provider_api_format,
);
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>,
request_api_format: Option<&str>,
response_body: Option<&Value>,
response_body_state: Option<UsageBodyCaptureState>,
provider_api_format: Option<&str>,
) -> 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,
request_api_format,
response_body,
response_body_state,
provider_api_format,
) {
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
@@ -312,6 +391,7 @@ pub(crate) fn attach_provider_actual_service_tier_metadata(
#[cfg(test)]
mod tests {
use aether_contracts::{ExecutionPlan, RequestBody};
use aether_data_contracts::repository::usage::UsageBodyCaptureState;
use serde_json::{json, Value};
use std::collections::BTreeMap;
@@ -323,8 +403,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 +883,44 @@ 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("openai:chat"),
Some(&json!({"model": "gpt-5.1"})),
Some(UsageBodyCaptureState::Inline),
Some("openai:chat"),
)
.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("openai:chat"),
Some(&json!({"model": "gpt-5"})),
Some(UsageBodyCaptureState::Inline),
Some("openai:chat"),
);
assert!(metadata.is_none());
let metadata = refresh_provider_response_model_metadata(
Some(json!({"provider_response_model": "gpt-5.1"})),
None,
Some(UsageBodyCaptureState::Disabled),
Some("openai:chat"),
Some(&json!({"model": "gpt-5.2"})),
Some(UsageBodyCaptureState::Inline),
Some("openai:chat"),
);
assert!(metadata.is_none());
}
#[test]
fn terminal_response_refresh_replaces_stale_actual_tier() {
let metadata = refresh_provider_response_body_metadata(
+14 -4
View File
@@ -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,17 @@ 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.api_format.as_deref(),
event.data.response_body.as_ref(),
event.data.response_body_state,
event.data.endpoint_api_format.as_deref(),
);
}
impl UsageQueueHealthSnapshot {
+31 -3
View File
@@ -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,15 @@ 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,
Some(client_contract.as_str()),
provider_response.as_ref(),
body_states.response_body_state,
Some(provider_contract.as_str()),
);
let mut data = UsageEventData {
user_id,
@@ -1047,6 +1057,15 @@ 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,
Some(context_seed.client_contract.as_str()),
provider_response_full.as_ref(),
provider_response_body_state,
Some(context_seed.provider_contract.as_str()),
);
TerminalUsageSeed {
terminal_state,
@@ -1225,6 +1244,15 @@ 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,
Some(context_seed.client_contract.as_str()),
provider_response_full.as_ref(),
provider_response_body_state,
Some(context_seed.provider_contract.as_str()),
);
// 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(
+1
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -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 级别
@@ -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<HeaderModelTextField>([
'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',
@@ -16,11 +16,22 @@
:class="modelClass"
data-usage-model-source
>{{ record.model }}</span>
<template v-if="actualModel">
<span
class="order-last basis-full min-w-0 break-all whitespace-normal text-muted-foreground"
data-usage-model-target
><span class="mr-1">-&gt;</span>{{ actualModel }}</span>
<template v-if="hasModelFacts">
<div
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-facts
>
<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 v-if="!shouldStackBadges">
<Badge
@@ -83,7 +94,7 @@ interface ModelBadgePresentation {
interface UsageModelDisplayRecord {
model: string
target_model?: string | null
model_version?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -113,15 +124,20 @@ const props = withDefaults(defineProps<{
showReasoningBadge: true,
})
const actualModel = computed(() => {
const mappingModel = computed(() => {
const targetModel = normalizeText(props.record.target_model)
if (targetModel && targetModel !== props.record.model) return targetModel
const modelVersion = normalizeText(props.record.model_version)
if (modelVersion && modelVersion !== props.record.model) return modelVersion
if (targetModel && targetModel !== normalizeText(props.record.model)) return targetModel
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 requested = normalizeText(props.record.requested_reasoning_effort)
const actual = normalizeText(props.record.reasoning_effort)
@@ -191,7 +207,7 @@ const modelBadges = computed<ModelBadgePresentation[]>(() => {
})
const shouldStackBadges = computed(() => (
actualModel.value === null && modelBadges.value.length >= 3
!hasModelFacts.value && modelBadges.value.length >= 3
))
function normalizeText(value: string | null | undefined): string | null {
@@ -1656,20 +1656,6 @@ function getApiFormatTooltip(record: UsageRecord): string {
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 {
const requested = record.requested_reasoning_effort?.trim()
const actual = record.reasoning_effort?.trim()
@@ -1744,15 +1730,18 @@ function getServiceTierTitle(record: UsageRecord): string {
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
if (actualModel) {
return `${record.model} -> ${actualModel}${suffix}`
}
return `${record.model}${suffix}`
const requestModel = record.model.trim()
const mappingModel = record.target_model?.trim()
const responseModel = record.response_model?.trim()
return [
requestModel,
mappingModel && mappingModel !== requestModel ? `映射模型: ${mappingModel}` : null,
responseModel && responseModel !== requestModel ? `响应模型: ${responseModel}` : null,
].filter((line): line is string => Boolean(line)).join('\n') + suffix
}
</script>
@@ -313,13 +313,14 @@ describe('RequestDetailDrawer settlement pricing', () => {
{ tier: 'priority', label: 'Fast', badgeKey: 'fast' },
{ tier: 'ultrafast', label: 'ultrafast', badgeKey: 'service-tier' },
{ tier: 'future-tier', label: 'future-tier', badgeKey: 'service-tier' },
])('shows mapping, reasoning, $tier, and Cyber together in the model header', async ({ tier, label, badgeKey }) => {
])('shows mapping, response model, reasoning, $tier, and Cyber together in the model header', async ({ tier, label, badgeKey }) => {
apiMocks.getRequestDetail.mockResolvedValue({
...buildEmbeddingDetail(),
id: 'usage-cyber-risk-demo',
request_id: 'req_usage-cyber-risk-demo',
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
request_body: {
@@ -382,10 +383,11 @@ describe('RequestDetailDrawer settlement pricing', () => {
)
const modelRow = modelLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-target]')?.classList.contains('basis-full'))
.toBe(true)
expect(modelRow?.classList.contains('flex-wrap')).toBe(true)
expect(modelRow?.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent)
.toContain('gpt-5.2')
expect(modelRow?.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
expect(modelRow?.querySelector(`[data-request-detail-model-badge="${badgeKey}"]`)?.textContent)
@@ -462,7 +464,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
.toContain('Cyber')
expect(document.body.querySelector('[data-usage-model-target]')?.textContent)
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent)
.toContain('gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
.toContain('xhigh -> max')
@@ -573,8 +575,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await vi.waitFor(() => {
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1')
expect(document.body.querySelector('[data-usage-model-mapping]')?.textContent?.trim())
.toBe('映射模型gpt-5.1')
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
.toBe('xhigh -> max')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -585,7 +587,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({
...buildEmbeddingDetail(),
id: 'usage-version-summary',
@@ -628,8 +630,8 @@ describe('RequestDetailDrawer settlement pricing', () => {
await nextTick()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
.toBe('->gpt-5.1-2026-07-17')
expect(document.body.querySelector('[data-usage-model-response]')).toBeNull()
expect(document.body.querySelector('[data-usage-model-mapping]')).toBeNull()
})
})
@@ -689,7 +691,7 @@ describe('RequestDetailDrawer settlement pricing', () => {
}
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())
.toBe('xhigh')
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
@@ -461,10 +461,11 @@ describe('UsageRecordsTable', () => {
.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({
model: 'gpt-5',
target_model: 'gpt-5.1',
response_model: 'gpt-5.2',
requested_reasoning_effort: 'xhigh',
reasoning_effort: 'max',
service_tier: 'priority',
@@ -477,6 +478,8 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('gpt-5')
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('Fast')
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
@@ -510,15 +513,35 @@ describe('UsageRecordsTable', () => {
expect(inlineLayout).not.toBeNull()
const modelRow = inlineLayout?.firstElementChild
expect(modelRow?.textContent).toContain('gpt-5')
expect(modelRow?.textContent).toContain('->')
expect(modelRow?.textContent).toContain('gpt-5.1')
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-mapping]')?.textContent).toContain('映射模型')
expect(modelRow?.querySelector('[data-usage-model-response]')?.textContent).toContain('响应模型')
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="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', () => {
const root = mountUsageRecordsTable([buildRecord({
model: 'gpt-5',
@@ -716,7 +716,13 @@ export function useUsageData(options: UseUsageDataOptions) {
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
? record.actual_service_tier
: 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
@@ -651,6 +651,10 @@ async function pollActiveRequests() {
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
? update.actual_service_tier
: null
// 活跃接口返回的是当前最终候选快照,服务端空值要清除旧响应模型。
record.response_model = typeof update.response_model === 'string' && update.response_model.trim()
? update.response_model
: null
// 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string') {
@@ -1150,6 +1154,7 @@ function handleDetailRequestState(update: {
reasoningEffort?: string | null
serviceTier?: string | null
actualServiceTier?: string | null
responseModel?: string | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
updatedAt?: string | null
@@ -1292,6 +1297,11 @@ function handleDetailRequestState(update: {
? update.actualServiceTier
: null
}
if ('responseModel' in update) {
record.response_model = typeof update.responseModel === 'string'
? update.responseModel
: null
}
if ('imageProgress' in update) {
const nextProgress = update.imageProgress ?? null
if (!sameImageProgress(record.image_progress, nextProgress)) {