mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
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:
@@ -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 {
|
||||
|
||||
+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_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 [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user