Merge origin/main into main

Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes.

Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed.

Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
elky
2026-10-02 11:57:18 +08:00
343 changed files with 27929 additions and 2549 deletions
@@ -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,
@@ -128,6 +129,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,
@@ -1334,6 +1341,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!({
@@ -25,11 +25,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,
@@ -56,9 +57,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
@@ -1096,9 +1238,15 @@ pub struct UsageAuditAggregationQuery {
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct UsageAuditSummaryQuery {
/// Optional provider-name allowlist, intersected with provider_name; empty matches nothing.
#[serde(default)]
pub provider_names: Option<Vec<String>>,
pub created_from_unix_secs: u64,
pub created_until_unix_secs: u64,
pub user_id: Option<String>,
/// Optional bulk user scope used by current user-group reporting.
/// An empty list intentionally matches no usage rows.
pub user_ids: Option<Vec<String>>,
pub provider_name: Option<String>,
pub model: Option<String>,
}
@@ -1486,11 +1634,17 @@ pub enum UsageTimeSeriesGranularity {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UsageTimeSeriesQuery {
/// Optional provider-name allowlist, intersected with provider_name; empty matches nothing.
#[serde(default)]
pub provider_names: Option<Vec<String>>,
pub created_from_unix_secs: u64,
pub created_until_unix_secs: u64,
pub granularity: UsageTimeSeriesGranularity,
pub tz_offset_minutes: i32,
pub user_id: Option<String>,
/// Optional bulk user scope used by current user-group reporting.
/// An empty list intentionally matches no usage rows.
pub user_ids: Option<Vec<String>>,
pub provider_name: Option<String>,
pub model: Option<String>,
}
@@ -1517,10 +1671,16 @@ pub enum UsageLeaderboardGroupBy {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UsageLeaderboardQuery {
/// Optional provider-name allowlist, intersected with provider_name; empty matches nothing.
#[serde(default)]
pub provider_names: Option<Vec<String>>,
pub created_from_unix_secs: u64,
pub created_until_unix_secs: u64,
pub group_by: UsageLeaderboardGroupBy,
pub user_id: Option<String>,
/// Optional bulk user scope used by current user-group reporting.
/// An empty list intentionally matches no usage rows.
pub user_ids: Option<Vec<String>>,
pub provider_name: Option<String>,
pub model: Option<String>,
}
@@ -2709,7 +2869,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,
@@ -2735,6 +2896,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 [
@@ -2463,6 +2463,59 @@ pub struct AdjustWalletBalanceInput {
pub balance_type: String,
pub operator_id: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub clamp_deduction_to_available_balance: bool,
#[serde(default)]
pub batch_context: Option<AdminUserWalletBalanceBatchContext>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AdminUserWalletBalanceBatchContext {
pub admin_user_id: String,
pub idempotency_key: String,
pub user_id: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PrepareAdminUserWalletBalanceBatchInput {
pub admin_user_id: String,
pub idempotency_key: String,
pub request_fingerprint: String,
pub target_user_ids: Vec<String>,
pub missing_user_ids: Vec<String>,
pub warnings: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredAdminUserWalletBalanceBatch {
pub admin_user_id: String,
pub idempotency_key: String,
pub request_fingerprint: String,
pub target_user_ids: Vec<String>,
pub missing_user_ids: Vec<String>,
pub warnings: Vec<serde_json::Value>,
pub user_outcomes: std::collections::BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum PrepareAdminUserWalletBalanceBatchOutcome {
Ready(StoredAdminUserWalletBalanceBatch),
Conflict,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "reason", rename_all = "snake_case")]
pub enum AdminUserWalletBalanceBatchUserOutcome {
Succeeded,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AdjustWalletBalanceInBatchInput {
pub admin_user_id: String,
pub idempotency_key: String,
pub user_id: String,
pub adjustment: AdjustWalletBalanceInput,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -2894,7 +2947,51 @@ pub trait WalletWriteRepository: Send + Sync {
async fn adjust_wallet_balance(
&self,
input: AdjustWalletBalanceInput,
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, crate::DataLayerError>;
) -> Result<
Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>,
crate::DataLayerError,
>;
async fn prepare_admin_user_wallet_balance_batch(
&self,
_input: PrepareAdminUserWalletBalanceBatchInput,
) -> Result<PrepareAdminUserWalletBalanceBatchOutcome, crate::DataLayerError> {
Err(crate::DataLayerError::InvalidInput(
"idempotent admin wallet batches are not available".to_string(),
))
}
async fn get_admin_user_wallet_balance_batch(
&self,
_admin_user_id: &str,
_idempotency_key: &str,
_request_fingerprint: &str,
) -> Result<Option<PrepareAdminUserWalletBalanceBatchOutcome>, crate::DataLayerError> {
Err(crate::DataLayerError::InvalidInput(
"idempotent admin wallet batches are not available".to_string(),
))
}
async fn adjust_admin_user_wallet_balance_batch_user(
&self,
_input: AdjustWalletBalanceInBatchInput,
) -> Result<AdminUserWalletBalanceBatchUserOutcome, crate::DataLayerError> {
Err(crate::DataLayerError::InvalidInput(
"idempotent admin wallet batches are not available".to_string(),
))
}
async fn record_admin_user_wallet_balance_batch_failure(
&self,
_admin_user_id: &str,
_idempotency_key: &str,
_user_id: &str,
_reason: &str,
) -> Result<AdminUserWalletBalanceBatchUserOutcome, crate::DataLayerError> {
Err(crate::DataLayerError::InvalidInput(
"idempotent admin wallet batches are not available".to_string(),
))
}
async fn create_manual_wallet_recharge(
&self,