mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
feat: expose upstream response model in usage records
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
+3
@@ -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 [
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">-></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>
|
||||||
|
|||||||
+8
-9
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user