mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 22:20:19 +08:00
merge(main): 解决 usage 展示与生命周期同步冲突
This commit is contained in:
@@ -2,7 +2,10 @@ use aether_data_contracts::repository::usage::UpsertUsageRecord;
|
||||
use aether_data_contracts::DataLayerError;
|
||||
|
||||
use crate::request_metadata::{
|
||||
attach_provider_request_body_metadata, sanitize_usage_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,
|
||||
};
|
||||
use crate::{UsageEvent, UsageEventType};
|
||||
|
||||
@@ -38,15 +41,42 @@ pub fn build_upsert_usage_record_from_event(
|
||||
}
|
||||
};
|
||||
let mut data = event.data.clone();
|
||||
data.request_metadata = attach_provider_request_body_metadata(
|
||||
data.request_metadata,
|
||||
data.endpoint_api_format
|
||||
.as_deref()
|
||||
.or(data.api_format.as_deref()),
|
||||
data.target_model.as_deref().or(Some(data.model.as_str())),
|
||||
Some(data.model.as_str()),
|
||||
// Request-derived facts are captured before body capture policy is applied. Do not let a
|
||||
// truncation/disabled placeholder clear those facts while converting the queued event into a
|
||||
// database record. Inline (or ref-loaded) bodies remain authoritative and may clear stale
|
||||
// metadata when the final upstream request no longer contains a value.
|
||||
match request_body_derived_facts_action(data.request_body.as_ref(), data.request_body_state) {
|
||||
RequestBodyDerivedFactsAction::Refresh => {
|
||||
data.request_metadata = attach_client_request_body_metadata(
|
||||
data.request_metadata,
|
||||
data.request_body.as_ref(),
|
||||
);
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Clear => {
|
||||
data.request_metadata = clear_client_request_body_metadata(data.request_metadata);
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Preserve => {}
|
||||
}
|
||||
match request_body_derived_facts_action(
|
||||
data.provider_request_body.as_ref(),
|
||||
);
|
||||
data.provider_request_body_state,
|
||||
) {
|
||||
RequestBodyDerivedFactsAction::Refresh => {
|
||||
data.request_metadata = attach_provider_request_body_metadata(
|
||||
data.request_metadata,
|
||||
data.endpoint_api_format
|
||||
.as_deref()
|
||||
.or(data.api_format.as_deref()),
|
||||
data.target_model.as_deref().or(Some(data.model.as_str())),
|
||||
Some(data.model.as_str()),
|
||||
data.provider_request_body.as_ref(),
|
||||
);
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Clear => {
|
||||
data.request_metadata = clear_provider_request_body_metadata(data.request_metadata);
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Preserve => {}
|
||||
}
|
||||
let now_unix_secs = event.timestamp_ms / 1_000;
|
||||
|
||||
Ok(UpsertUsageRecord {
|
||||
@@ -165,6 +195,8 @@ fn empty_to_none(value: Option<String>) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_data_contracts::repository::usage::UsageBodyCaptureState;
|
||||
|
||||
use crate::{UsageEvent, UsageEventData, UsageEventType};
|
||||
|
||||
use super::build_upsert_usage_record_from_event;
|
||||
@@ -186,6 +218,9 @@ mod tests {
|
||||
output_tokens: Some(20),
|
||||
total_tokens: Some(30),
|
||||
status_code: Some(200),
|
||||
request_body: Some(serde_json::json!({
|
||||
"reasoning": { "effort": "xhigh" }
|
||||
})),
|
||||
provider_request_body: Some(serde_json::json!({
|
||||
"reasoning": { "effort": "max" },
|
||||
"service_tier": "priority"
|
||||
@@ -202,6 +237,14 @@ mod tests {
|
||||
assert_eq!(record.status, "completed");
|
||||
assert_eq!(record.billing_status, "pending");
|
||||
assert_eq!(record.total_tokens, Some(30));
|
||||
assert_eq!(
|
||||
record
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("requested_reasoning_effort"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("xhigh")
|
||||
);
|
||||
assert_eq!(
|
||||
record
|
||||
.request_metadata
|
||||
@@ -229,6 +272,139 @@ mod tests {
|
||||
assert_eq!(record.finalized_at_unix_secs, Some(1_700_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_request_placeholders_preserve_pre_capture_request_facts() {
|
||||
let truncated = serde_json::json!({
|
||||
"truncated": true,
|
||||
"reason": "body_capture_limit_exceeded",
|
||||
"max_bytes": 128,
|
||||
"source_bytes": 2048,
|
||||
"value_kind": "object"
|
||||
});
|
||||
let record = build_upsert_usage_record_from_event(&UsageEvent {
|
||||
event_type: UsageEventType::Completed,
|
||||
request_id: "req-truncated-request-facts".to_string(),
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
data: UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
api_format: Some("openai:responses".to_string()),
|
||||
endpoint_api_format: Some("openai:responses".to_string()),
|
||||
request_body: Some(truncated.clone()),
|
||||
request_body_state: Some(UsageBodyCaptureState::Truncated),
|
||||
provider_request_body: Some(truncated),
|
||||
provider_request_body_state: Some(UsageBodyCaptureState::Truncated),
|
||||
request_metadata: Some(serde_json::json!({
|
||||
"requested_reasoning_effort": "xhigh",
|
||||
"provider_reasoning_effort": "max",
|
||||
"provider_service_tier": "priority"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
})
|
||||
.expect("record should build");
|
||||
|
||||
let metadata = record
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.expect("derived request facts should remain");
|
||||
assert_eq!(metadata["requested_reasoning_effort"], "xhigh");
|
||||
assert_eq!(metadata["provider_reasoning_effort"], "max");
|
||||
assert_eq!(metadata["provider_service_tier"], "priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_reference_and_unavailable_bodies_preserve_pre_capture_request_facts() {
|
||||
for state in [
|
||||
UsageBodyCaptureState::Disabled,
|
||||
UsageBodyCaptureState::Reference,
|
||||
UsageBodyCaptureState::Unavailable,
|
||||
] {
|
||||
let record = build_upsert_usage_record_from_event(&UsageEvent {
|
||||
event_type: UsageEventType::Completed,
|
||||
request_id: format!("req-preserved-{state:?}"),
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
data: UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
request_body_state: Some(state),
|
||||
provider_request_body_state: Some(state),
|
||||
request_metadata: Some(serde_json::json!({
|
||||
"requested_reasoning_effort": "xhigh",
|
||||
"provider_reasoning_effort": "max",
|
||||
"provider_service_tier": "priority"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
})
|
||||
.expect("record should build");
|
||||
|
||||
let metadata = record
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.expect("derived request facts should remain");
|
||||
assert_eq!(metadata["requested_reasoning_effort"], "xhigh");
|
||||
assert_eq!(metadata["provider_reasoning_effort"], "max");
|
||||
assert_eq!(metadata["provider_service_tier"], "priority");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_complete_factless_final_bodies_clear_stale_request_facts() {
|
||||
let cases = [
|
||||
("typed-missing", Some(UsageBodyCaptureState::None), None),
|
||||
(
|
||||
"typed-missing-with-stale-body",
|
||||
Some(UsageBodyCaptureState::None),
|
||||
Some(serde_json::json!({
|
||||
"reasoning_effort": "xhigh",
|
||||
"service_tier": "priority"
|
||||
})),
|
||||
),
|
||||
("untyped-missing", None, None),
|
||||
(
|
||||
"inline-without-facts",
|
||||
Some(UsageBodyCaptureState::Inline),
|
||||
Some(serde_json::json!({"model": "gpt-5"})),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, state, body) in cases {
|
||||
let record = build_upsert_usage_record_from_event(&UsageEvent {
|
||||
event_type: UsageEventType::Completed,
|
||||
request_id: format!("req-clear-{name}"),
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
data: UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
request_body: body.clone(),
|
||||
request_body_state: state,
|
||||
provider_request_body: body,
|
||||
provider_request_body_state: state,
|
||||
request_metadata: Some(serde_json::json!({
|
||||
"trace_id": "trace-1",
|
||||
"requested_reasoning_effort": "xhigh",
|
||||
"provider_reasoning_effort": "max",
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "priority"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
})
|
||||
.expect("record should build");
|
||||
|
||||
let metadata = record
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.expect("audit facts remain");
|
||||
assert_eq!(metadata["trace_id"], "trace-1");
|
||||
assert_eq!(metadata["provider_actual_service_tier"], "priority");
|
||||
assert!(metadata.get("requested_reasoning_effort").is_none());
|
||||
assert!(metadata.get("provider_reasoning_effort").is_none());
|
||||
assert!(metadata.get("provider_service_tier").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_terminal_record_is_void_for_billing() {
|
||||
let record = build_upsert_usage_record_from_event(&UsageEvent {
|
||||
|
||||
@@ -6,9 +6,10 @@ 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,
|
||||
normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, UsageBodyCaptureState,
|
||||
PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY,
|
||||
PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
REQUESTED_REASONING_EFFORT_METADATA_KEY,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
@@ -17,6 +18,53 @@ const MAX_USAGE_REQUEST_METADATA_NODES: usize = 4_000;
|
||||
const MAX_USAGE_REQUEST_METADATA_BYTES: usize = 16 * 1024;
|
||||
const MAX_USAGE_REQUEST_METADATA_STRING_BYTES: usize = 1_024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RequestBodyDerivedFactsAction {
|
||||
Refresh,
|
||||
Preserve,
|
||||
Clear,
|
||||
}
|
||||
|
||||
pub(crate) fn request_body_derived_facts_action(
|
||||
request_body: Option<&Value>,
|
||||
state: Option<UsageBodyCaptureState>,
|
||||
) -> RequestBodyDerivedFactsAction {
|
||||
// A typed `none` marker is produced by the final capture attempt. It must take precedence over
|
||||
// a body value that may have survived from an earlier candidate. A missing marker (`None`)
|
||||
// remains compatible with legacy events, where a present body is still authoritative.
|
||||
if state == Some(UsageBodyCaptureState::None) {
|
||||
return RequestBodyDerivedFactsAction::Clear;
|
||||
}
|
||||
|
||||
if let Some(request_body) = request_body {
|
||||
if matches!(
|
||||
state,
|
||||
Some(
|
||||
UsageBodyCaptureState::Truncated
|
||||
| UsageBodyCaptureState::Disabled
|
||||
| UsageBodyCaptureState::Unavailable
|
||||
)
|
||||
) || request_body.as_object().is_some_and(|body| {
|
||||
body.get("truncated").and_then(Value::as_bool) == Some(true)
|
||||
&& body.get("reason").and_then(Value::as_str) == Some("body_capture_limit_exceeded")
|
||||
}) {
|
||||
return RequestBodyDerivedFactsAction::Preserve;
|
||||
}
|
||||
return RequestBodyDerivedFactsAction::Refresh;
|
||||
}
|
||||
|
||||
match state {
|
||||
Some(
|
||||
UsageBodyCaptureState::Inline
|
||||
| UsageBodyCaptureState::Reference
|
||||
| UsageBodyCaptureState::Truncated
|
||||
| UsageBodyCaptureState::Disabled
|
||||
| UsageBodyCaptureState::Unavailable,
|
||||
) => RequestBodyDerivedFactsAction::Preserve,
|
||||
Some(UsageBodyCaptureState::None) | None => RequestBodyDerivedFactsAction::Clear,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_usage_request_metadata_seed(
|
||||
_plan: &ExecutionPlan,
|
||||
context: Option<&Map<String, Value>>,
|
||||
@@ -76,6 +124,32 @@ pub(crate) fn sanitize_usage_request_metadata_ref(value: Option<&Value>) -> Opti
|
||||
(!filtered.is_empty()).then_some(Value::Object(filtered))
|
||||
}
|
||||
|
||||
pub(crate) fn attach_client_request_body_metadata(
|
||||
metadata: Option<Value>,
|
||||
request_body: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let request_body_is_object = request_body.and_then(Value::as_object).is_some();
|
||||
let reasoning_effort = extract_provider_reasoning_effort_from_body(request_body);
|
||||
if !request_body_is_object && reasoning_effort.is_none() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
let mut object = match metadata {
|
||||
Some(Value::Object(object)) => object,
|
||||
_ => Map::new(),
|
||||
};
|
||||
if request_body_is_object {
|
||||
object.remove(REQUESTED_REASONING_EFFORT_METADATA_KEY);
|
||||
}
|
||||
if let Some(reasoning_effort) = reasoning_effort {
|
||||
object.insert(
|
||||
REQUESTED_REASONING_EFFORT_METADATA_KEY.to_string(),
|
||||
Value::String(reasoning_effort),
|
||||
);
|
||||
}
|
||||
(!object.is_empty()).then_some(Value::Object(object))
|
||||
}
|
||||
|
||||
pub(crate) fn attach_provider_request_body_metadata(
|
||||
metadata: Option<Value>,
|
||||
provider_api_format: Option<&str>,
|
||||
@@ -129,6 +203,31 @@ pub(crate) fn attach_provider_request_body_metadata(
|
||||
(!object.is_empty()).then_some(Value::Object(object))
|
||||
}
|
||||
|
||||
pub(crate) fn clear_client_request_body_metadata(metadata: Option<Value>) -> Option<Value> {
|
||||
clear_request_metadata_fields(metadata, &[REQUESTED_REASONING_EFFORT_METADATA_KEY])
|
||||
}
|
||||
|
||||
pub(crate) fn clear_provider_request_body_metadata(metadata: Option<Value>) -> Option<Value> {
|
||||
clear_request_metadata_fields(
|
||||
metadata,
|
||||
&[
|
||||
PROVIDER_REASONING_EFFORT_METADATA_KEY,
|
||||
PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_request_metadata_fields(metadata: Option<Value>, keys: &[&str]) -> Option<Value> {
|
||||
let Some(Value::Object(mut object)) = metadata else {
|
||||
return None;
|
||||
};
|
||||
for key in keys {
|
||||
object.remove(*key);
|
||||
}
|
||||
(!object.is_empty()).then_some(Value::Object(object))
|
||||
}
|
||||
|
||||
pub(crate) fn attach_provider_response_body_metadata(
|
||||
metadata: Option<Value>,
|
||||
provider_response_body: Option<&Value>,
|
||||
@@ -148,6 +247,47 @@ pub(crate) fn attach_provider_response_body_metadata(
|
||||
attach_provider_actual_service_tier_metadata(metadata, actual_service_tier.as_deref())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// metadata is preserved for those cases.
|
||||
pub(crate) fn refresh_provider_response_body_metadata(
|
||||
metadata: Option<Value>,
|
||||
provider_response_body: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let is_capture_placeholder = provider_response_body
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|body| {
|
||||
body.get("truncated").and_then(Value::as_bool) == Some(true)
|
||||
&& body.get("reason").and_then(Value::as_str) == Some("body_capture_limit_exceeded")
|
||||
});
|
||||
let body_is_complete_object =
|
||||
provider_response_body.and_then(Value::as_object).is_some() && !is_capture_placeholder;
|
||||
let actual_service_tier =
|
||||
extract_provider_actual_service_tier_from_response(provider_response_body)
|
||||
.and_then(|value| normalize_provider_service_tier(&value));
|
||||
let Some(actual_service_tier) = actual_service_tier else {
|
||||
if !body_is_complete_object {
|
||||
return metadata;
|
||||
}
|
||||
let Some(Value::Object(mut object)) = metadata else {
|
||||
return None;
|
||||
};
|
||||
object.remove(PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY);
|
||||
return (!object.is_empty()).then_some(Value::Object(object));
|
||||
};
|
||||
|
||||
let mut object = match metadata {
|
||||
Some(Value::Object(object)) => object,
|
||||
_ => Map::new(),
|
||||
};
|
||||
object.insert(
|
||||
PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY.to_string(),
|
||||
Value::String(actual_service_tier),
|
||||
);
|
||||
(!object.is_empty()).then_some(Value::Object(object))
|
||||
}
|
||||
|
||||
pub(crate) fn attach_provider_actual_service_tier_metadata(
|
||||
metadata: Option<Value>,
|
||||
actual_service_tier: Option<&str>,
|
||||
@@ -179,6 +319,7 @@ fn copy_allowed_metadata_fields(source: &Map<String, Value>, target: &mut Map<St
|
||||
copy_non_empty_string(source, target, "request_path");
|
||||
copy_non_empty_string(source, target, "request_query_string");
|
||||
copy_non_empty_string(source, target, "request_path_and_query");
|
||||
copy_non_empty_string(source, target, REQUESTED_REASONING_EFFORT_METADATA_KEY);
|
||||
copy_non_empty_string(source, target, PROVIDER_REASONING_EFFORT_METADATA_KEY);
|
||||
copy_non_empty_string(source, target, PROVIDER_SERVICE_TIER_METADATA_KEY);
|
||||
copy_non_empty_string(source, target, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY);
|
||||
@@ -226,6 +367,7 @@ fn move_allowed_metadata_fields(mut source: Map<String, Value>, target: &mut Map
|
||||
remove_non_empty_string(&mut source, target, "request_path");
|
||||
remove_non_empty_string(&mut source, target, "request_query_string");
|
||||
remove_non_empty_string(&mut source, target, "request_path_and_query");
|
||||
remove_non_empty_string(&mut source, target, REQUESTED_REASONING_EFFORT_METADATA_KEY);
|
||||
remove_non_empty_string(&mut source, target, PROVIDER_REASONING_EFFORT_METADATA_KEY);
|
||||
remove_non_empty_string(&mut source, target, PROVIDER_SERVICE_TIER_METADATA_KEY);
|
||||
remove_non_empty_string(
|
||||
@@ -517,10 +659,16 @@ mod tests {
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
apply_usage_body_capture_policy_to_event, UsageBodyCapturePolicy, UsageEvent,
|
||||
UsageEventData, UsageEventType, UsageRequestRecordLevel,
|
||||
};
|
||||
|
||||
use super::{
|
||||
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,
|
||||
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,
|
||||
sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref,
|
||||
MAX_USAGE_REQUEST_METADATA_BYTES, MAX_USAGE_REQUEST_METADATA_DEPTH,
|
||||
MAX_USAGE_REQUEST_METADATA_NODES,
|
||||
@@ -830,6 +978,27 @@ mod tests {
|
||||
assert_eq!(metadata, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_request_body_metadata_preserves_requested_reasoning_mapping_source() {
|
||||
let updated = attach_client_request_body_metadata(
|
||||
Some(json!({
|
||||
"trace_id": "trace-1",
|
||||
"requested_reasoning_effort": "low"
|
||||
})),
|
||||
Some(&json!({
|
||||
"reasoning": { "effort": "XHigh" }
|
||||
})),
|
||||
)
|
||||
.expect("metadata should remain");
|
||||
|
||||
assert_eq!(updated["requested_reasoning_effort"], "xhigh");
|
||||
|
||||
let cleared =
|
||||
attach_client_request_body_metadata(Some(updated), Some(&json!({ "model": "gpt-5" })))
|
||||
.expect("trace metadata should remain");
|
||||
assert!(cleared.get("requested_reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_request_body_metadata_uses_final_provider_body_as_source_of_truth() {
|
||||
let metadata = Some(json!({
|
||||
@@ -880,6 +1049,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_provider_request_tier_survives_basic_body_capture_as_derived_metadata() {
|
||||
let request_body = json!({
|
||||
"model": "gpt-5",
|
||||
"reasoning": { "effort": "xhigh" }
|
||||
});
|
||||
let provider_request_body = json!({
|
||||
"model": "gpt-5",
|
||||
"reasoning": { "effort": "max" },
|
||||
"service_tier": "priority"
|
||||
});
|
||||
let request_metadata = attach_client_request_body_metadata(None, Some(&request_body));
|
||||
let request_metadata = attach_provider_request_body_metadata(
|
||||
request_metadata,
|
||||
Some("openai:responses"),
|
||||
Some("gpt-5"),
|
||||
Some("gpt-5"),
|
||||
Some(&provider_request_body),
|
||||
);
|
||||
let mut event = UsageEvent::new(
|
||||
UsageEventType::Completed,
|
||||
"req-final-provider-tier",
|
||||
UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
request_body: Some(request_body),
|
||||
provider_request_body: Some(provider_request_body),
|
||||
request_metadata,
|
||||
..UsageEventData::default()
|
||||
},
|
||||
);
|
||||
|
||||
apply_usage_body_capture_policy_to_event(
|
||||
UsageBodyCapturePolicy {
|
||||
record_level: UsageRequestRecordLevel::Basic,
|
||||
max_request_body_bytes: Some(1024),
|
||||
max_response_body_bytes: Some(1024),
|
||||
},
|
||||
&mut event,
|
||||
);
|
||||
|
||||
assert_eq!(event.data.request_body, None);
|
||||
assert_eq!(event.data.provider_request_body, None);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("requested_reasoning_effort")),
|
||||
Some(&json!("xhigh"))
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("provider_reasoning_effort")),
|
||||
Some(&json!("max"))
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("provider_service_tier")),
|
||||
Some(&json!("priority"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_response_metadata_preserves_terminal_actual_service_tier() {
|
||||
let metadata = attach_provider_response_body_metadata(
|
||||
@@ -902,6 +1140,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_response_refresh_replaces_stale_actual_tier() {
|
||||
let metadata = refresh_provider_response_body_metadata(
|
||||
Some(json!({"provider_actual_service_tier": "priority"})),
|
||||
Some(&json!({"service_tier": "Default"})),
|
||||
)
|
||||
.expect("actual tier should remain");
|
||||
assert_eq!(metadata["provider_actual_service_tier"], "default");
|
||||
|
||||
let metadata = refresh_provider_response_body_metadata(
|
||||
Some(json!({
|
||||
"trace_id": "trace-1",
|
||||
"provider_actual_service_tier": "priority"
|
||||
})),
|
||||
Some(&json!({"id": "response-without-tier"})),
|
||||
)
|
||||
.expect("un-tiered complete response should remain auditable");
|
||||
assert_eq!(metadata, json!({"trace_id": "trace-1"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_response_refresh_preserves_summary_over_capture_placeholder() {
|
||||
let metadata = refresh_provider_response_body_metadata(
|
||||
Some(json!({"provider_actual_service_tier": "priority"})),
|
||||
Some(&json!({
|
||||
"truncated": true,
|
||||
"reason": "body_capture_limit_exceeded"
|
||||
})),
|
||||
)
|
||||
.expect("summary should survive placeholder capture");
|
||||
assert_eq!(metadata["provider_actual_service_tier"], "priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_summary_tier_uses_the_same_normalized_metadata_field() {
|
||||
let metadata = attach_provider_actual_service_tier_metadata(
|
||||
|
||||
@@ -13,7 +13,12 @@ use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::executor::spawn_on_usage_background_runtime;
|
||||
use crate::request_metadata::attach_provider_response_body_metadata;
|
||||
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,
|
||||
};
|
||||
use crate::worker::{
|
||||
build_usage_queue_worker_with_record_gate, UsageWorkerControl, UsageWorkerObservation,
|
||||
};
|
||||
@@ -1264,6 +1269,7 @@ impl UsageRuntime {
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
preserve_request_facts(event);
|
||||
preserve_provider_response_facts(event);
|
||||
match self.cached_body_capture_policy(data).await {
|
||||
Ok(policy) => apply_usage_body_capture_policy_to_event(policy, event),
|
||||
@@ -1734,6 +1740,44 @@ impl UsageRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn preserve_request_facts(event: &mut UsageEvent) {
|
||||
let data = &mut event.data;
|
||||
match request_body_derived_facts_action(data.request_body.as_ref(), data.request_body_state) {
|
||||
RequestBodyDerivedFactsAction::Refresh => {
|
||||
data.request_metadata = attach_client_request_body_metadata(
|
||||
data.request_metadata.take(),
|
||||
data.request_body.as_ref(),
|
||||
);
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Clear => {
|
||||
data.request_metadata =
|
||||
clear_client_request_body_metadata(data.request_metadata.take());
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Preserve => {}
|
||||
}
|
||||
match request_body_derived_facts_action(
|
||||
data.provider_request_body.as_ref(),
|
||||
data.provider_request_body_state,
|
||||
) {
|
||||
RequestBodyDerivedFactsAction::Refresh => {
|
||||
data.request_metadata = attach_provider_request_body_metadata(
|
||||
data.request_metadata.take(),
|
||||
data.endpoint_api_format
|
||||
.as_deref()
|
||||
.or(data.api_format.as_deref()),
|
||||
data.target_model.as_deref().or(Some(data.model.as_str())),
|
||||
Some(data.model.as_str()),
|
||||
data.provider_request_body.as_ref(),
|
||||
);
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Clear => {
|
||||
data.request_metadata =
|
||||
clear_provider_request_body_metadata(data.request_metadata.take());
|
||||
}
|
||||
RequestBodyDerivedFactsAction::Preserve => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn preserve_provider_response_facts(event: &mut UsageEvent) {
|
||||
let metadata = event.data.request_metadata.take();
|
||||
event.data.request_metadata =
|
||||
@@ -2989,7 +3033,9 @@ mod tests {
|
||||
use aether_data_contracts::repository::settlement::{
|
||||
StoredUsageSettlement, UsageSettlementInput,
|
||||
};
|
||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UpsertUsageRecord};
|
||||
use aether_data_contracts::repository::usage::{
|
||||
StoredRequestUsageAudit, UpsertUsageRecord, UsageBodyCaptureState,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeQueueStore, RuntimeState};
|
||||
use async_trait::async_trait;
|
||||
@@ -2998,9 +3044,10 @@ mod tests {
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
|
||||
use super::{
|
||||
preserve_provider_response_facts, LifecycleEventCoalescer, UsageBillingEventEnricher,
|
||||
UsageBodyCapturePolicy, UsageEnqueueRetryDispatcher, UsageRequestRecordLevel,
|
||||
UsageRuntimeAccess, UsageWorkerObservation, UsageWorkerSupervisorState,
|
||||
preserve_provider_response_facts, preserve_request_facts, LifecycleEventCoalescer,
|
||||
UsageBillingEventEnricher, UsageBodyCapturePolicy, UsageEnqueueRetryDispatcher,
|
||||
UsageRequestRecordLevel, UsageRuntimeAccess, UsageWorkerObservation,
|
||||
UsageWorkerSupervisorState,
|
||||
};
|
||||
use crate::worker::ManualProxyNodeCounter;
|
||||
use crate::{
|
||||
@@ -5816,6 +5863,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_request_facts_ignores_post_capture_truncation_placeholders() {
|
||||
let truncated = json!({
|
||||
"truncated": true,
|
||||
"reason": "body_capture_limit_exceeded"
|
||||
});
|
||||
let mut event = UsageEvent::new(
|
||||
UsageEventType::Completed,
|
||||
"req-truncated-preserve",
|
||||
UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
request_body: Some(truncated.clone()),
|
||||
request_body_state: Some(UsageBodyCaptureState::Truncated),
|
||||
provider_request_body: Some(truncated),
|
||||
provider_request_body_state: Some(UsageBodyCaptureState::Truncated),
|
||||
request_metadata: Some(json!({
|
||||
"requested_reasoning_effort": "xhigh",
|
||||
"provider_reasoning_effort": "max",
|
||||
"provider_service_tier": "priority"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
);
|
||||
|
||||
preserve_request_facts(&mut event);
|
||||
|
||||
let metadata = event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.expect("metadata remains");
|
||||
assert_eq!(metadata["requested_reasoning_effort"], "xhigh");
|
||||
assert_eq!(metadata["provider_reasoning_effort"], "max");
|
||||
assert_eq!(metadata["provider_service_tier"], "priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_request_facts_clears_stale_facts_when_final_bodies_are_missing() {
|
||||
let mut event = UsageEvent::new(
|
||||
UsageEventType::Completed,
|
||||
"req-missing-final-body",
|
||||
UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
request_body_state: Some(UsageBodyCaptureState::None),
|
||||
provider_request_body_state: Some(UsageBodyCaptureState::None),
|
||||
request_metadata: Some(json!({
|
||||
"requested_reasoning_effort": "xhigh",
|
||||
"provider_reasoning_effort": "max",
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "priority"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
);
|
||||
|
||||
preserve_request_facts(&mut event);
|
||||
|
||||
let metadata = event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.expect("response audit fact remains");
|
||||
assert!(metadata.get("requested_reasoning_effort").is_none());
|
||||
assert!(metadata.get("provider_reasoning_effort").is_none());
|
||||
assert!(metadata.get("provider_service_tier").is_none());
|
||||
assert_eq!(metadata["provider_actual_service_tier"], "priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_request_record_level_strips_body_capture_but_preserves_derived_fields() {
|
||||
let mut event = UsageEvent::new(
|
||||
@@ -5826,9 +5943,16 @@ mod tests {
|
||||
model: "gpt-5".to_string(),
|
||||
total_tokens: Some(42),
|
||||
error_message: Some("upstream failed".to_string()),
|
||||
request_body: Some(json!({"messages":[{"role":"user","content":"hello"}]})),
|
||||
request_body: Some(json!({
|
||||
"messages":[{"role":"user","content":"hello"}],
|
||||
"reasoning": {"effort": "xhigh"}
|
||||
})),
|
||||
request_body_ref: Some("usage://request/req-basic-1/request_body".to_string()),
|
||||
provider_request_body: Some(json!({"model":"gpt-5"})),
|
||||
provider_request_body: Some(json!({
|
||||
"model":"gpt-5",
|
||||
"reasoning": {"effort": "max"},
|
||||
"service_tier": "priority"
|
||||
})),
|
||||
provider_request_body_ref: Some(
|
||||
"usage://request/req-basic-1/provider_request_body".to_string(),
|
||||
),
|
||||
@@ -5841,11 +5965,16 @@ mod tests {
|
||||
client_response_body_ref: Some(
|
||||
"usage://request/req-basic-1/client_response_body".to_string(),
|
||||
),
|
||||
request_metadata: Some(json!({"provider_service_tier": "priority"})),
|
||||
request_metadata: Some(json!({
|
||||
"requested_reasoning_effort": "low",
|
||||
"provider_reasoning_effort": "medium",
|
||||
"provider_service_tier": "standard"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
);
|
||||
|
||||
preserve_request_facts(&mut event);
|
||||
preserve_provider_response_facts(&mut event);
|
||||
apply_usage_body_capture_policy_to_event(
|
||||
UsageBodyCapturePolicy {
|
||||
@@ -5865,6 +5994,33 @@ mod tests {
|
||||
assert!(event.data.response_body_ref.is_none());
|
||||
assert!(event.data.client_response_body.is_none());
|
||||
assert!(event.data.client_response_body_ref.is_none());
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("requested_reasoning_effort"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("xhigh")
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("provider_reasoning_effort"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("max")
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("provider_service_tier"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
|
||||
@@ -13,10 +13,11 @@ use crate::body_capture::{
|
||||
RuntimeBodyCaptureMetadataInput,
|
||||
};
|
||||
use crate::request_metadata::{
|
||||
attach_provider_actual_service_tier_metadata, attach_provider_request_body_metadata,
|
||||
attach_provider_response_body_metadata, build_usage_request_metadata_seed,
|
||||
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,
|
||||
sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref,
|
||||
refresh_provider_response_body_metadata, sanitize_usage_request_metadata,
|
||||
sanitize_usage_request_metadata_ref,
|
||||
};
|
||||
use crate::{
|
||||
map_usage_from_response, stream_capture_terminal_state, GatewayStreamReportRequest,
|
||||
@@ -696,6 +697,8 @@ fn build_terminal_usage_event_from_seed_impl(
|
||||
} else {
|
||||
merge_usage_request_metadata(request_metadata, audit_payload)
|
||||
};
|
||||
let request_metadata =
|
||||
attach_client_request_body_metadata(request_metadata, request_body.as_ref());
|
||||
let request_metadata = attach_provider_request_body_metadata(
|
||||
request_metadata,
|
||||
Some(provider_contract.as_str()),
|
||||
@@ -1021,7 +1024,7 @@ pub fn build_sync_terminal_usage_seed(
|
||||
status_code,
|
||||
provider_response_full.as_ref(),
|
||||
);
|
||||
let request_metadata = attach_provider_response_body_metadata(
|
||||
let request_metadata = refresh_provider_response_body_metadata(
|
||||
context_seed.request_metadata,
|
||||
provider_response_full.as_ref(),
|
||||
);
|
||||
@@ -1199,12 +1202,16 @@ pub fn build_stream_terminal_usage_seed(
|
||||
missing_observed_finish,
|
||||
terminal_error_message.is_some(),
|
||||
);
|
||||
let request_metadata = attach_provider_actual_service_tier_metadata(
|
||||
let request_metadata = refresh_provider_response_body_metadata(
|
||||
context_seed.request_metadata,
|
||||
provider_response_full.as_ref(),
|
||||
);
|
||||
// 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(
|
||||
request_metadata,
|
||||
provider_actual_service_tier.as_deref(),
|
||||
);
|
||||
let request_metadata =
|
||||
attach_provider_response_body_metadata(request_metadata, provider_response_full.as_ref());
|
||||
|
||||
TerminalUsageSeed {
|
||||
terminal_state,
|
||||
@@ -2127,7 +2134,10 @@ fn build_runtime_request_metadata_seed_from_parts(
|
||||
metadata.insert("body_size".to_string(), body_size);
|
||||
}
|
||||
|
||||
(!metadata.is_empty()).then_some(Value::Object(metadata))
|
||||
attach_client_request_body_metadata(
|
||||
(!metadata.is_empty()).then_some(Value::Object(metadata)),
|
||||
context_value_ref(context, "original_request_body"),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_runtime_body_size_metadata(
|
||||
|
||||
Reference in New Issue
Block a user