mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
merge(main): 解决 usage 展示与生命周期同步冲突
This commit is contained in:
@@ -133,6 +133,17 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
|
||||
);
|
||||
|
||||
let Some(context) = input.routing_context.as_ref() else {
|
||||
// Cache identity headers are projected only at the terminal boundary. Any non-empty
|
||||
// session headers already present here are explicit client or header-rule inputs and stay
|
||||
// authoritative.
|
||||
if let Some(provider_request_body) = decision.provider_request_body.as_ref() {
|
||||
crate::ai_serving::apply_codex_openai_responses_identity_headers(
|
||||
&mut decision.provider_request_headers,
|
||||
provider_request_body,
|
||||
provider_type.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let provider_body_rules = decision
|
||||
@@ -182,6 +193,14 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
|
||||
})?;
|
||||
ensure_report_context_routing_trace(input, decision, &policy);
|
||||
if policy.mutation_plan.is_empty() {
|
||||
if let Some(provider_request_body) = decision.provider_request_body.as_ref() {
|
||||
crate::ai_serving::apply_codex_openai_responses_identity_headers(
|
||||
&mut decision.provider_request_headers,
|
||||
provider_request_body,
|
||||
provider_type.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if original_provider_request_body.is_none() && !policy.mutation_plan.body_patch.is_empty() {
|
||||
@@ -255,6 +274,12 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
|
||||
input.requested_model.as_str(),
|
||||
)
|
||||
});
|
||||
crate::ai_serving::apply_codex_openai_responses_identity_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
provider_type.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
crate::ai_serving::apply_codex_openai_responses_lite_header_for_request_body_with_capabilities(
|
||||
&mut provider_request_headers,
|
||||
Some(&provider_request_body),
|
||||
@@ -1279,6 +1304,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_prompt_cache_identity_headers_are_terminal_after_routing_mutations() {
|
||||
let mut input = sample_decision_input();
|
||||
input
|
||||
.routing_context
|
||||
.as_mut()
|
||||
.expect("routing context")
|
||||
.client_api_format = "openai:responses".to_string();
|
||||
set_provider_request_rules(
|
||||
&mut input,
|
||||
&["gpt-5"],
|
||||
json!([{
|
||||
"type": "patch_headers",
|
||||
"patch": [
|
||||
{"op": "remove", "name": "session-id"},
|
||||
{"op": "remove", "name": "thread-id"}
|
||||
]
|
||||
}]),
|
||||
);
|
||||
let identity = "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3";
|
||||
let mut decision = sample_decision();
|
||||
decision.provider_type = Some("codex".to_string());
|
||||
decision.provider_api_format = Some("openai:responses".to_string());
|
||||
decision.client_api_format = Some("openai:responses".to_string());
|
||||
decision.provider_request_body = Some(json!({
|
||||
"model": "gpt-5",
|
||||
"input": [],
|
||||
"prompt_cache_key": identity,
|
||||
"client_metadata": {
|
||||
"session_id": identity,
|
||||
"thread_id": identity
|
||||
}
|
||||
}));
|
||||
assert!(!decision.provider_request_headers.contains_key("session-id"));
|
||||
assert!(!decision.provider_request_headers.contains_key("thread-id"));
|
||||
|
||||
apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
|
||||
.expect("terminal Codex identity contract should be restored");
|
||||
|
||||
assert_eq!(
|
||||
decision
|
||||
.provider_request_headers
|
||||
.get("session-id")
|
||||
.map(String::as_str),
|
||||
Some(identity)
|
||||
);
|
||||
assert_eq!(
|
||||
decision
|
||||
.provider_request_headers
|
||||
.get("thread-id")
|
||||
.map(String::as_str),
|
||||
Some(identity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_prompt_cache_identity_headers_fail_closed_after_body_identity_removal() {
|
||||
let mut input = sample_decision_input();
|
||||
input
|
||||
.routing_context
|
||||
.as_mut()
|
||||
.expect("routing context")
|
||||
.client_api_format = "openai:responses".to_string();
|
||||
set_provider_request_rules(
|
||||
&mut input,
|
||||
&["gpt-5"],
|
||||
json!([{
|
||||
"type": "json_patch_body",
|
||||
"patch": [
|
||||
{"op": "remove", "path": "/prompt_cache_key"},
|
||||
{"op": "remove", "path": "/client_metadata"}
|
||||
]
|
||||
}]),
|
||||
);
|
||||
let identity = "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3";
|
||||
let mut decision = sample_decision();
|
||||
decision.provider_type = Some("codex".to_string());
|
||||
decision.provider_api_format = Some("openai:responses".to_string());
|
||||
decision.client_api_format = Some("openai:responses".to_string());
|
||||
decision.provider_request_body = Some(json!({
|
||||
"model": "gpt-5",
|
||||
"input": [],
|
||||
"prompt_cache_key": identity,
|
||||
"client_metadata": {
|
||||
"session_id": identity,
|
||||
"thread_id": identity
|
||||
}
|
||||
}));
|
||||
assert!(!decision.provider_request_headers.contains_key("session-id"));
|
||||
assert!(!decision.provider_request_headers.contains_key("thread-id"));
|
||||
|
||||
apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
|
||||
.expect("terminal Codex identity contract should fail closed");
|
||||
|
||||
let body = decision.provider_request_body.as_ref().expect("body");
|
||||
assert!(body.get("prompt_cache_key").is_none());
|
||||
assert!(body.get("client_metadata").is_none());
|
||||
assert!(!decision.provider_request_headers.contains_key("session-id"));
|
||||
assert!(!decision.provider_request_headers.contains_key("thread-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_compact_contract_is_terminal_after_routing_mutations() {
|
||||
let mut input = sample_decision_input();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
mod tests;
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
|
||||
apply_codex_openai_responses_identity_headers, apply_codex_openai_responses_special_body_edits,
|
||||
apply_codex_openai_special_headers,
|
||||
};
|
||||
|
||||
pub(crate) fn codex_model_capabilities_for_transport(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
|
||||
codex_model_capabilities,
|
||||
apply_codex_openai_responses_identity_headers, apply_codex_openai_responses_special_body_edits,
|
||||
apply_codex_openai_special_headers, codex_model_capabilities,
|
||||
};
|
||||
use crate::ai_serving::planner::standard::{
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_responses_request_body,
|
||||
};
|
||||
use crate::ai_serving::planner::standard::build_local_openai_responses_request_body;
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -180,11 +182,11 @@ fn does_not_synthesize_prompt_cache_key_from_api_key_identity() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_prompt_cache_key_for_codex_requests() {
|
||||
fn adapts_generic_prompt_cache_key_to_codex_native_identity() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "existing-key",
|
||||
"prompt_cache_key": "ltm-pc-v2-5557e02f5c9b447a97673ba330dbe77a",
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
@@ -195,11 +197,291 @@ fn keeps_existing_prompt_cache_key_for_codex_requests() {
|
||||
Some("key-123"),
|
||||
);
|
||||
|
||||
assert_eq!(body["prompt_cache_key"], "existing-key");
|
||||
let expected_identity = "d9c5d122-7c1c-5fb1-ba9d-656062eda44e";
|
||||
assert_eq!(body["prompt_cache_key"], expected_identity);
|
||||
assert_eq!(body["client_metadata"]["session_id"], expected_identity);
|
||||
assert_eq!(body["client_metadata"]["thread_id"], expected_identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_identity_headers_without_deriving_session_headers_from_body() {
|
||||
fn preserves_native_codex_cache_identity_and_metadata() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "guardian:parent-thread",
|
||||
"client_metadata": {
|
||||
"session_id": "native-session",
|
||||
"thread_id": "native-thread",
|
||||
"turn_id": "native-turn"
|
||||
}
|
||||
});
|
||||
let expected = body.clone();
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some("key-123"),
|
||||
);
|
||||
|
||||
assert_eq!(body["prompt_cache_key"], expected["prompt_cache_key"]);
|
||||
assert_eq!(body["client_metadata"], expected["client_metadata"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_uuid_prompt_cache_key_while_completing_codex_identity() {
|
||||
let identity = "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3";
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": identity
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["prompt_cache_key"], identity);
|
||||
assert_eq!(body["client_metadata"]["session_id"], identity);
|
||||
assert_eq!(body["client_metadata"]["thread_id"], identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_codex_prompt_cache_domains_distinct() {
|
||||
let mut first = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "tenant-a"
|
||||
});
|
||||
let mut second = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "tenant-b"
|
||||
});
|
||||
|
||||
for body in [&mut first, &mut second] {
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
assert_ne!(first["prompt_cache_key"], second["prompt_cache_key"]);
|
||||
assert_eq!(
|
||||
first["prompt_cache_key"],
|
||||
first["client_metadata"]["session_id"]
|
||||
);
|
||||
assert_eq!(
|
||||
second["prompt_cache_key"],
|
||||
second["client_metadata"]["session_id"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_partial_and_null_codex_client_metadata() {
|
||||
let mut partial = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "generic-affinity",
|
||||
"client_metadata": {
|
||||
"thread_id": "native-thread",
|
||||
"caller": "sdk"
|
||||
}
|
||||
});
|
||||
let mut null_metadata = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "generic-affinity",
|
||||
"client_metadata": null
|
||||
});
|
||||
let mut null_session = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "generic-affinity",
|
||||
"client_metadata": {
|
||||
"session_id": null,
|
||||
"thread_id": null,
|
||||
"caller": "sdk"
|
||||
}
|
||||
});
|
||||
|
||||
for body in [&mut partial, &mut null_metadata, &mut null_session] {
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(partial["client_metadata"]["thread_id"], "native-thread");
|
||||
assert_eq!(partial["client_metadata"]["caller"], "sdk");
|
||||
assert_eq!(
|
||||
partial["client_metadata"]["session_id"],
|
||||
partial["prompt_cache_key"]
|
||||
);
|
||||
assert_eq!(
|
||||
null_metadata["client_metadata"]["session_id"],
|
||||
null_metadata["prompt_cache_key"]
|
||||
);
|
||||
assert_eq!(
|
||||
null_metadata["client_metadata"]["thread_id"],
|
||||
null_metadata["prompt_cache_key"]
|
||||
);
|
||||
assert_eq!(
|
||||
null_session["client_metadata"]["session_id"],
|
||||
null_session["prompt_cache_key"]
|
||||
);
|
||||
assert_eq!(
|
||||
null_session["client_metadata"]["thread_id"],
|
||||
null_session["prompt_cache_key"]
|
||||
);
|
||||
assert_eq!(null_session["client_metadata"]["caller"], "sdk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_malformed_codex_client_metadata_unchanged() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "generic-affinity",
|
||||
"client_metadata": "invalid"
|
||||
});
|
||||
let mut malformed_fields = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "generic-affinity",
|
||||
"client_metadata": {
|
||||
"session_id": 42,
|
||||
"thread_id": ""
|
||||
}
|
||||
});
|
||||
let expected_malformed_metadata = malformed_fields["client_metadata"].clone();
|
||||
|
||||
for candidate in [&mut body, &mut malformed_fields] {
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
candidate,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(body["prompt_cache_key"], "generic-affinity");
|
||||
assert_eq!(body["client_metadata"], "invalid");
|
||||
assert_eq!(malformed_fields["prompt_cache_key"], "generic-affinity");
|
||||
assert_eq!(
|
||||
malformed_fields["client_metadata"],
|
||||
expected_malformed_metadata
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_prompt_cache_identity_adaptation_to_codex_responses_family() {
|
||||
let original = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "generic-affinity"
|
||||
});
|
||||
let mut standard_openai = original.clone();
|
||||
let mut codex_compact = original.clone();
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut standard_openai,
|
||||
"openai",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut codex_compact,
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(standard_openai, original);
|
||||
assert_ne!(codex_compact["prompt_cache_key"], "generic-affinity");
|
||||
assert!(codex_compact.get("client_metadata").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_to_codex_responses_adapts_prompt_cache_identity_end_to_end() {
|
||||
let body = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"prompt_cache_key": "ltm-pc-v2-5557e02f5c9b447a97673ba330dbe77a"
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body,
|
||||
"gpt-5.6-luna",
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
true,
|
||||
false,
|
||||
"codex",
|
||||
None,
|
||||
None,
|
||||
&HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.expect("chat to Codex Responses request should build");
|
||||
|
||||
let expected_identity = "d9c5d122-7c1c-5fb1-ba9d-656062eda44e";
|
||||
assert_eq!(provider_request_body["prompt_cache_key"], expected_identity);
|
||||
assert_eq!(
|
||||
provider_request_body["client_metadata"]["session_id"],
|
||||
expected_identity
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["client_metadata"]["thread_id"],
|
||||
expected_identity
|
||||
);
|
||||
|
||||
let mut provider_request_headers = BTreeMap::new();
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:responses",
|
||||
Some("trace-codex-cache-identity"),
|
||||
None,
|
||||
);
|
||||
apply_codex_openai_responses_identity_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_headers
|
||||
.get("session-id")
|
||||
.map(String::as_str),
|
||||
Some(expected_identity)
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_headers
|
||||
.get("thread-id")
|
||||
.map(String::as_str),
|
||||
Some(expected_identity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_uuid_prompt_cache_identity_into_missing_session_headers() {
|
||||
let mut headers = BTreeMap::new();
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
@@ -215,7 +497,7 @@ fn injects_identity_headers_without_deriving_session_headers_from_body() {
|
||||
Some("trace-codex-123"),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
|
||||
apply_codex_openai_responses_identity_headers(&mut headers, &body, "codex", "openai:responses");
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acc-123".to_string())
|
||||
@@ -228,8 +510,88 @@ fn injects_identity_headers_without_deriving_session_headers_from_body() {
|
||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||
assert!(!headers.contains_key("version"));
|
||||
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
|
||||
assert_eq!(headers.get("session-id"), None);
|
||||
assert_eq!(headers.get("thread-id"), None);
|
||||
assert_eq!(
|
||||
headers.get("session-id").map(String::as_str),
|
||||
Some("172c39e6-c0a0-5a70-8b63-e0f8e0d185a3")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("thread-id").map(String::as_str),
|
||||
Some("172c39e6-c0a0-5a70-8b63-e0f8e0d185a3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_native_codex_metadata_for_non_uuid_cache_overrides() {
|
||||
let mut headers = BTreeMap::new();
|
||||
let body = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"prompt_cache_key": "guardian:parent-thread",
|
||||
"client_metadata": {
|
||||
"session_id": "019f687b-8e92-7842-9631-d5bf0dba0a3b",
|
||||
"thread_id": "019f6d20-1111-7222-8333-444455556666"
|
||||
}
|
||||
});
|
||||
|
||||
apply_codex_openai_special_headers(
|
||||
&mut headers,
|
||||
&body,
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
apply_codex_openai_responses_identity_headers(&mut headers, &body, "codex", "openai:responses");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("session-id").map(String::as_str),
|
||||
Some("019f687b-8e92-7842-9631-d5bf0dba0a3b")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("thread-id").map(String::as_str),
|
||||
Some("019f6d20-1111-7222-8333-444455556666")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_native_cache_keys_out_of_identity_headers() {
|
||||
let mut headers = BTreeMap::new();
|
||||
let body = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"prompt_cache_key": "generic-cache-key"
|
||||
});
|
||||
|
||||
apply_codex_openai_special_headers(
|
||||
&mut headers,
|
||||
&body,
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
apply_codex_openai_responses_identity_headers(&mut headers, &body, "codex", "openai:responses");
|
||||
|
||||
assert!(!headers.contains_key("session-id"));
|
||||
assert!(!headers.contains_key("thread-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_malformed_native_metadata_out_of_identity_headers() {
|
||||
let mut headers = BTreeMap::new();
|
||||
let body = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||
"client_metadata": {
|
||||
"session_id": 42,
|
||||
"thread_id": ""
|
||||
}
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_identity_headers(&mut headers, &body, "codex", "openai:responses");
|
||||
|
||||
assert!(!headers.contains_key("session-id"));
|
||||
assert!(!headers.contains_key("thread-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -247,7 +609,6 @@ fn injects_only_codex_client_headers_for_images_requests() {
|
||||
Some("trace-codex-image-123"),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acc-123".to_string())
|
||||
@@ -330,6 +691,7 @@ fn preserves_client_context_headers_and_enforces_codex_provider_identity() {
|
||||
Some("trace-codex-123"),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
apply_codex_openai_responses_identity_headers(&mut headers, &body, "codex", "openai:responses");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("x-client-request-id"),
|
||||
@@ -371,7 +733,7 @@ fn preserves_client_context_headers_and_enforces_codex_provider_identity() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_does_not_derive_session_headers_from_body() {
|
||||
fn compact_projects_uuid_prompt_cache_identity_into_session_headers() {
|
||||
let mut headers = BTreeMap::new();
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
@@ -387,6 +749,12 @@ fn compact_does_not_derive_session_headers_from_body() {
|
||||
Some("trace-codex-compact-123"),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
apply_codex_openai_responses_identity_headers(
|
||||
&mut headers,
|
||||
&body,
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
@@ -400,6 +768,12 @@ fn compact_does_not_derive_session_headers_from_body() {
|
||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||
assert!(!headers.contains_key("version"));
|
||||
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
|
||||
assert_eq!(headers.get("session-id"), None);
|
||||
assert_eq!(headers.get("thread-id"), None);
|
||||
assert_eq!(
|
||||
headers.get("session-id").map(String::as_str),
|
||||
Some("172c39e6-c0a0-5a70-8b63-e0f8e0d185a3")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("thread-id").map(String::as_str),
|
||||
Some("172c39e6-c0a0-5a70-8b63-e0f8e0d185a3")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub(crate) use aether_ai_formats::api::{
|
||||
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
|
||||
api_format_alias_matches, api_format_storage_aliases,
|
||||
apply_codex_openai_compact_terminal_headers, apply_codex_openai_responses_chat_body_edits,
|
||||
apply_codex_openai_responses_identity_headers,
|
||||
apply_codex_openai_responses_lite_header_for_request_body_with_capabilities,
|
||||
apply_codex_openai_responses_lite_header_with_capabilities,
|
||||
apply_codex_openai_responses_special_body_edits,
|
||||
|
||||
@@ -131,6 +131,7 @@ const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const SSE_KEEPALIVE_BYTES: &[u8] = b": aether-keepalive\n\n";
|
||||
const SSE_CONTROL_FILTER_MAX_BUFFER_BYTES: usize = 1024 * 1024;
|
||||
const SSE_TERMINAL_DETECTOR_MAX_LINE_BYTES: usize = 1024 * 1024;
|
||||
const PROVIDER_STREAM_ERROR_INSPECTION_MAX_BYTES: usize = SSE_TERMINAL_DETECTOR_MAX_LINE_BYTES;
|
||||
const STREAM_IDLE_LOG_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const STREAM_IDLE_LOG_INTERVAL_MS: u64 = 60_000;
|
||||
const REWRITTEN_STREAM_PREFETCH_TIMEOUT: Duration = Duration::from_millis(750);
|
||||
@@ -140,6 +141,78 @@ const DIRECT_PASSTHROUGH_CHANNEL_CAPACITY_ENV: &str =
|
||||
"AETHER_GATEWAY_DIRECT_PASSTHROUGH_CHANNEL_CAPACITY";
|
||||
const DIRECT_PASSTHROUGH_MODE_ENV: &str = "AETHER_GATEWAY_DIRECT_PASSTHROUGH_MODE";
|
||||
|
||||
/// Retains the incomplete tail needed to recognize provider error events split across transport
|
||||
/// chunks without retaining an unbounded copy of the stream.
|
||||
#[derive(Default)]
|
||||
struct ProviderStreamErrorInspection {
|
||||
buffered: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ProviderStreamErrorInspection {
|
||||
fn observe(&mut self, report_context: Option<&Value>, chunk: &[u8]) -> Option<Value> {
|
||||
if chunk.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(error_body) = extract_provider_private_stream_error_body(report_context, chunk)
|
||||
{
|
||||
return Some(error_body);
|
||||
}
|
||||
|
||||
self.append_rolling(chunk);
|
||||
let error_body = extract_provider_private_stream_error_body(report_context, &self.buffered);
|
||||
if error_body.is_none() {
|
||||
self.trim_completed_sse_events();
|
||||
}
|
||||
error_body
|
||||
}
|
||||
|
||||
fn append_rolling(&mut self, chunk: &[u8]) {
|
||||
if chunk.len() >= PROVIDER_STREAM_ERROR_INSPECTION_MAX_BYTES {
|
||||
self.buffered.clear();
|
||||
self.buffered.extend_from_slice(
|
||||
&chunk[chunk.len() - PROVIDER_STREAM_ERROR_INSPECTION_MAX_BYTES..],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let overflow = self
|
||||
.buffered
|
||||
.len()
|
||||
.saturating_add(chunk.len())
|
||||
.saturating_sub(PROVIDER_STREAM_ERROR_INSPECTION_MAX_BYTES);
|
||||
if overflow > 0 {
|
||||
self.buffered.drain(..overflow);
|
||||
}
|
||||
self.buffered.extend_from_slice(chunk);
|
||||
}
|
||||
|
||||
fn trim_completed_sse_events(&mut self) {
|
||||
let Ok(text) = std::str::from_utf8(&self.buffered) else {
|
||||
return;
|
||||
};
|
||||
if !text.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
line.starts_with("event:") || line.starts_with("data:") || line.starts_with(':')
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lf_end = self
|
||||
.buffered
|
||||
.windows(2)
|
||||
.rposition(|window| window == b"\n\n")
|
||||
.map(|index| index + 2);
|
||||
let crlf_end = self
|
||||
.buffered
|
||||
.windows(4)
|
||||
.rposition(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4);
|
||||
if let Some(event_end) = lf_end.into_iter().chain(crlf_end).max() {
|
||||
self.buffered.drain(..event_end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StageElapsedGuard {
|
||||
stage: &'static str,
|
||||
started_at: Instant,
|
||||
@@ -1192,6 +1265,7 @@ struct DirectPassthroughFinalizerCore {
|
||||
stream_usage_report_context: Option<Value>,
|
||||
stream_usage_observer: Option<StreamingStandardTerminalObserver>,
|
||||
stream_usage_observer_buffered: Vec<u8>,
|
||||
provider_error_inspection: ProviderStreamErrorInspection,
|
||||
provider_buffered_body: Vec<u8>,
|
||||
buffered_body: Vec<u8>,
|
||||
provider_body_truncated: bool,
|
||||
@@ -1318,10 +1392,10 @@ impl DirectPassthroughFinalizer {
|
||||
chunk.as_ref(),
|
||||
);
|
||||
}
|
||||
if let Some(error_body_json) = extract_provider_private_stream_error_body(
|
||||
core.stream_usage_report_context.as_ref(),
|
||||
chunk.as_ref(),
|
||||
) {
|
||||
if let Some(error_body_json) = core
|
||||
.provider_error_inspection
|
||||
.observe(core.stream_usage_report_context.as_ref(), chunk.as_ref())
|
||||
{
|
||||
let error_status_code =
|
||||
resolve_local_sync_error_status_code(core.status_code, &error_body_json);
|
||||
core.terminal_failure = Some(build_stream_failure_from_provider_error_body(
|
||||
@@ -1486,6 +1560,7 @@ impl DirectPassthroughFinalizerCore {
|
||||
stream_usage_report_context,
|
||||
stream_usage_observer: _,
|
||||
stream_usage_observer_buffered: _,
|
||||
provider_error_inspection: _,
|
||||
provider_buffered_body,
|
||||
buffered_body,
|
||||
provider_body_truncated,
|
||||
@@ -2207,6 +2282,7 @@ async fn execute_stream_from_direct_passthrough(
|
||||
stream_usage_report_context,
|
||||
stream_usage_observer,
|
||||
stream_usage_observer_buffered: Vec::new(),
|
||||
provider_error_inspection: ProviderStreamErrorInspection::default(),
|
||||
provider_buffered_body: Vec::new(),
|
||||
buffered_body: Vec::new(),
|
||||
provider_body_truncated: false,
|
||||
@@ -2286,6 +2362,7 @@ async fn execute_stream_from_direct_passthrough(
|
||||
.as_ref()
|
||||
.map(|_| StreamingStandardTerminalObserver::default());
|
||||
let mut stream_usage_observer_buffered = Vec::new();
|
||||
let mut provider_error_inspection = ProviderStreamErrorInspection::default();
|
||||
let mut provider_buffered_body = Vec::new();
|
||||
let mut buffered_body = Vec::new();
|
||||
let mut provider_body_truncated = false;
|
||||
@@ -2458,7 +2535,7 @@ async fn execute_stream_from_direct_passthrough(
|
||||
provider_chunk.as_ref(),
|
||||
);
|
||||
}
|
||||
let provider_private_error_body_json = extract_provider_private_stream_error_body(
|
||||
let provider_private_error_body_json = provider_error_inspection.observe(
|
||||
stream_usage_report_context.as_ref(),
|
||||
provider_chunk.as_ref(),
|
||||
);
|
||||
@@ -5007,6 +5084,7 @@ async fn execute_stream_from_frame_stream(
|
||||
.filter(|_| !sync_json_stream_bridge_active_for_report)
|
||||
.map(|_| StreamingStandardTerminalObserver::default());
|
||||
let mut stream_usage_observer_buffered = Vec::new();
|
||||
let mut provider_error_inspection = ProviderStreamErrorInspection::default();
|
||||
append_stream_capture_bytes(
|
||||
&mut provider_buffered_body,
|
||||
&provider_prefetched_body_for_report,
|
||||
@@ -5168,6 +5246,16 @@ async fn execute_stream_from_frame_stream(
|
||||
let replay_chunk = normalized_prefetched_chunk
|
||||
.as_deref()
|
||||
.unwrap_or(provider_prefetched_body_for_report.as_slice());
|
||||
if let Some(error_body_json) = provider_error_inspection
|
||||
.observe(stream_usage_report_context.as_ref(), replay_chunk)
|
||||
{
|
||||
let error_status_code =
|
||||
resolve_local_sync_error_status_code(status_code, &error_body_json);
|
||||
terminal_failure = Some(build_stream_failure_from_provider_error_body(
|
||||
error_status_code,
|
||||
&error_body_json,
|
||||
));
|
||||
}
|
||||
if let (Some(observer), Some(report_context)) = (
|
||||
stream_usage_observer.as_mut(),
|
||||
stream_usage_report_context.as_ref(),
|
||||
@@ -5343,11 +5431,8 @@ async fn execute_stream_from_frame_stream(
|
||||
} else {
|
||||
chunk
|
||||
};
|
||||
let provider_private_error_body_json =
|
||||
extract_provider_private_stream_error_body(
|
||||
stream_usage_report_context.as_ref(),
|
||||
&normalized_chunk,
|
||||
);
|
||||
let provider_private_error_body_json = provider_error_inspection
|
||||
.observe(stream_usage_report_context.as_ref(), &normalized_chunk);
|
||||
if let (Some(observer), Some(report_context)) = (
|
||||
stream_usage_observer.as_mut(),
|
||||
stream_usage_report_context.as_ref(),
|
||||
@@ -5508,11 +5593,8 @@ async fn execute_stream_from_frame_stream(
|
||||
{
|
||||
match normalizer.finish() {
|
||||
Ok(normalized_chunk) if !normalized_chunk.is_empty() => {
|
||||
let provider_private_error_body_json =
|
||||
extract_provider_private_stream_error_body(
|
||||
stream_usage_report_context.as_ref(),
|
||||
&normalized_chunk,
|
||||
);
|
||||
let provider_private_error_body_json = provider_error_inspection
|
||||
.observe(stream_usage_report_context.as_ref(), &normalized_chunk);
|
||||
if let (Some(observer), Some(report_context)) = (
|
||||
stream_usage_observer.as_mut(),
|
||||
stream_usage_report_context.as_ref(),
|
||||
@@ -6114,7 +6196,7 @@ mod tests {
|
||||
stream_terminal_summary_missing_observed_finish,
|
||||
stream_terminal_summary_missing_observed_finish_with_requirement,
|
||||
stream_terminal_summary_represents_failure_with_requirement,
|
||||
ClientVisibleStreamCompletionTracker, DirectPassthroughMode,
|
||||
ClientVisibleStreamCompletionTracker, DirectPassthroughMode, ProviderStreamErrorInspection,
|
||||
};
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::stage_metrics::RequestStageTrace;
|
||||
@@ -6238,6 +6320,51 @@ mod tests {
|
||||
.observe_chunk(b"data: {\"type\":\"response.completed\",\"response\":{}}\r\n\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_error_inspection_detects_response_failed_at_every_chunk_boundary() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"status\":\"in_progress\"}}\n\n",
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"type\":\"invalid_request\",\"message\":\"cyber policy rejected the request\",\"code\":\"cyber_policy_violation\",\"param\":\"input\"}}}\n\n",
|
||||
)
|
||||
.as_bytes();
|
||||
|
||||
for split in 1..body.len() {
|
||||
let mut inspection = ProviderStreamErrorInspection::default();
|
||||
let detected = inspection
|
||||
.observe(None, &body[..split])
|
||||
.or_else(|| inspection.observe(None, &body[split..]))
|
||||
.unwrap_or_else(|| panic!("response.failed was missed at byte split {split}"));
|
||||
|
||||
assert_eq!(
|
||||
detected.pointer("/error/code"),
|
||||
Some(&json!("cyber_policy_violation")),
|
||||
"string provider code changed at byte split {split}"
|
||||
);
|
||||
assert_eq!(
|
||||
detected.pointer("/error/param"),
|
||||
Some(&json!("input")),
|
||||
"provider error fields changed at byte split {split}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut inspection = ProviderStreamErrorInspection::default();
|
||||
let mut detected = None;
|
||||
for byte in body.chunks(1) {
|
||||
if let Some(error_body) = inspection.observe(None, byte) {
|
||||
detected = Some(error_body);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let detected = detected.expect("byte-wise response.failed stream should be detected");
|
||||
assert_eq!(
|
||||
detected.pointer("/error/code"),
|
||||
Some(&json!("cyber_policy_violation"))
|
||||
);
|
||||
assert_eq!(detected.pointer("/error/param"), Some(&json!("input")));
|
||||
}
|
||||
|
||||
fn tunnel_proxy_snapshot(base_url: String) -> aether_contracts::ProxySnapshot {
|
||||
aether_contracts::ProxySnapshot {
|
||||
enabled: Some(true),
|
||||
|
||||
@@ -20,10 +20,9 @@ use crate::execution_runtime::submission::{
|
||||
use crate::log_ids::short_request_id;
|
||||
use crate::orchestration::{
|
||||
apply_local_execution_effect, resolve_local_failover_analysis_for_attempt,
|
||||
trace_upstream_response_body, with_upstream_response_report_context,
|
||||
LocalAdaptiveRateLimitEffect, LocalAttemptFailureEffect, LocalExecutionEffect,
|
||||
LocalExecutionEffectContext, LocalHealthFailureEffect, LocalOAuthInvalidationEffect,
|
||||
LocalPoolErrorEffect,
|
||||
with_upstream_response_report_context, LocalAdaptiveRateLimitEffect, LocalAttemptFailureEffect,
|
||||
LocalExecutionEffect, LocalExecutionEffectContext, LocalHealthFailureEffect,
|
||||
LocalOAuthInvalidationEffect, LocalPoolErrorEffect,
|
||||
};
|
||||
use crate::request_candidate_runtime::record_report_request_candidate_status;
|
||||
use crate::request_diagnostics::attach_current_request_diagnostics_to_report_context;
|
||||
@@ -36,6 +35,7 @@ pub(super) struct StreamFailureReport {
|
||||
pub(super) error_type: String,
|
||||
pub(super) error_message: String,
|
||||
extra_error_fields: Map<String, Value>,
|
||||
provider_body_json: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -54,20 +54,28 @@ struct StreamFailureBodyFields<'a> {
|
||||
}
|
||||
|
||||
impl StreamFailureReport {
|
||||
fn into_body_json(self) -> Value {
|
||||
fn into_body_jsons(self) -> (Value, Option<Value>) {
|
||||
let Self {
|
||||
status_code,
|
||||
error_type,
|
||||
error_message,
|
||||
mut extra_error_fields,
|
||||
provider_body_json,
|
||||
} = self;
|
||||
extra_error_fields.insert("type".to_string(), Value::String(error_type));
|
||||
extra_error_fields.insert("message".to_string(), Value::String(error_message));
|
||||
extra_error_fields.insert("code".to_string(), Value::from(status_code));
|
||||
Value::Object(Map::from_iter([(
|
||||
let normalized_body = Value::Object(Map::from_iter([(
|
||||
"error".to_string(),
|
||||
Value::Object(extra_error_fields),
|
||||
)]))
|
||||
)]));
|
||||
match provider_body_json {
|
||||
Some(provider_body) if provider_body != normalized_body => {
|
||||
(provider_body, Some(normalized_body))
|
||||
}
|
||||
Some(provider_body) => (provider_body, None),
|
||||
None => (normalized_body, None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn to_json_string(&self) -> serde_json::Result<String> {
|
||||
@@ -94,6 +102,7 @@ pub(super) fn build_stream_failure_report(
|
||||
error_type,
|
||||
error_message,
|
||||
extra_error_fields: Map::new(),
|
||||
provider_body_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +133,7 @@ pub(super) fn build_stream_failure_from_execution_error(
|
||||
error_type,
|
||||
error_message,
|
||||
extra_error_fields: error_object,
|
||||
provider_body_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +160,7 @@ pub(super) fn build_stream_failure_from_provider_error_body(
|
||||
error_type,
|
||||
error_message,
|
||||
extra_error_fields: Map::new(),
|
||||
provider_body_json: Some(body_json.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,19 +196,33 @@ fn build_stream_failure_sync_payload(
|
||||
failure: StreamFailureReport,
|
||||
) -> GatewaySyncReportRequest {
|
||||
let status_code = failure.status_code;
|
||||
let body = trace_upstream_response_body(None, provider_buffered_body);
|
||||
let (body, client_body) = failure.into_body_jsons();
|
||||
headers.retain(|name, _| {
|
||||
!name.eq_ignore_ascii_case("content-encoding")
|
||||
&& !name.eq_ignore_ascii_case("content-length")
|
||||
&& !name.eq_ignore_ascii_case("content-type")
|
||||
});
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
let report_context = with_upstream_response_report_context(
|
||||
report_context.as_ref(),
|
||||
status_code,
|
||||
Some(&headers),
|
||||
body.as_ref(),
|
||||
Some(&body),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.or(report_context);
|
||||
headers.remove("content-encoding");
|
||||
headers.remove("content-length");
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
let report_context = report_context.map(|mut context| {
|
||||
if let Some(object) = context.as_object_mut() {
|
||||
let response_headers = serde_json::to_value(&headers).unwrap_or(Value::Null);
|
||||
object.insert(
|
||||
"provider_response_headers".to_string(),
|
||||
response_headers.clone(),
|
||||
);
|
||||
object.insert("client_response_headers".to_string(), response_headers);
|
||||
}
|
||||
context
|
||||
});
|
||||
|
||||
GatewaySyncReportRequest {
|
||||
trace_id: trace_id.to_string(),
|
||||
@@ -205,8 +230,8 @@ fn build_stream_failure_sync_payload(
|
||||
report_context,
|
||||
status_code,
|
||||
headers,
|
||||
body_json: Some(failure.into_body_json()),
|
||||
client_body_json: None,
|
||||
body_json: Some(body),
|
||||
client_body_json: client_body,
|
||||
body_base64: (!provider_buffered_body.is_empty())
|
||||
.then(|| base64::engine::general_purpose::STANDARD.encode(provider_buffered_body)),
|
||||
telemetry,
|
||||
@@ -218,8 +243,9 @@ fn stream_failure_body_field<'a>(
|
||||
field: &str,
|
||||
) -> Option<&'a str> {
|
||||
payload
|
||||
.body_json
|
||||
.client_body_json
|
||||
.as_ref()
|
||||
.or(payload.body_json.as_ref())
|
||||
.and_then(|body_json| body_json.get("error"))
|
||||
.and_then(|value| value.get(field))
|
||||
.and_then(Value::as_str)
|
||||
@@ -477,3 +503,151 @@ pub(super) async fn submit_midstream_stream_failure(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{build_stream_failure_from_provider_error_body, build_stream_failure_sync_payload};
|
||||
|
||||
#[test]
|
||||
fn midstream_failure_trace_uses_terminal_error_instead_of_buffered_sse() {
|
||||
let provider_buffered_body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"instructions\":\"AGENTS.md secret prompt\",\"tools\":[{\"name\":\"update_plan\"}]}}\n\n",
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"type\":\"invalid_request\",\"message\":\"This content was flagged for possible cybersecurity risk.\",\"code\":\"cyber_policy_violation\",\"param\":\"input\",\"details\":{\"policy_category\":\"cybersecurity\",\"appeal_allowed\":true}}}}\n\n",
|
||||
)
|
||||
.as_bytes();
|
||||
let terminal_error = aether_ai_formats::api::extract_provider_private_stream_error_body(
|
||||
None,
|
||||
provider_buffered_body,
|
||||
)
|
||||
.expect("raw upstream SSE should expose its terminal provider error JSON");
|
||||
let failure = build_stream_failure_from_provider_error_body(400, &terminal_error);
|
||||
|
||||
let payload = build_stream_failure_sync_payload(
|
||||
"trace-cyber-policy",
|
||||
"openai_responses_sync_error".to_string(),
|
||||
Some(json!({"request_id": "request-cyber-policy"})),
|
||||
BTreeMap::from([
|
||||
("Content-Encoding".to_string(), "gzip".to_string()),
|
||||
("Content-Length".to_string(), "4096".to_string()),
|
||||
("Content-Type".to_string(), "text/event-stream".to_string()),
|
||||
(
|
||||
"x-request-id".to_string(),
|
||||
"req_usage-cyber-risk-demo".to_string(),
|
||||
),
|
||||
]),
|
||||
None,
|
||||
provider_buffered_body,
|
||||
failure,
|
||||
);
|
||||
|
||||
let trace_body = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.pointer("/upstream_response/body"))
|
||||
.expect("candidate trace should include the terminal error body");
|
||||
assert_eq!(
|
||||
trace_body,
|
||||
payload.body_json.as_ref().expect("usage error body")
|
||||
);
|
||||
assert_eq!(trace_body, &terminal_error);
|
||||
assert_eq!(trace_body["error"]["type"], json!("invalid_request"));
|
||||
assert_eq!(
|
||||
trace_body["error"]["message"],
|
||||
json!("This content was flagged for possible cybersecurity risk.")
|
||||
);
|
||||
assert_eq!(trace_body["error"]["code"], json!("cyber_policy_violation"));
|
||||
assert_eq!(trace_body["error"]["param"], json!("input"));
|
||||
assert_eq!(
|
||||
trace_body["error"]["details"],
|
||||
json!({
|
||||
"policy_category": "cybersecurity",
|
||||
"appeal_allowed": true
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.pointer("/upstream_response/headers/content-type")),
|
||||
Some(&json!("application/json"))
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.pointer("/upstream_response/headers/x-request-id")),
|
||||
Some(&json!("req_usage-cyber-risk-demo"))
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.pointer("/provider_response_headers/content-type")),
|
||||
Some(&json!("application/json"))
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.pointer("/client_response_headers/content-type")),
|
||||
Some(&json!("application/json"))
|
||||
);
|
||||
let trace_headers = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.pointer("/upstream_response/headers"))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("candidate trace should include terminal JSON headers");
|
||||
assert!(!trace_headers
|
||||
.keys()
|
||||
.any(|name| name.eq_ignore_ascii_case("content-encoding")));
|
||||
assert!(!trace_headers
|
||||
.keys()
|
||||
.any(|name| name.eq_ignore_ascii_case("content-length")));
|
||||
assert_eq!(
|
||||
payload.headers.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert!(!payload
|
||||
.headers
|
||||
.keys()
|
||||
.any(|name| name.eq_ignore_ascii_case("content-encoding")));
|
||||
assert!(!payload
|
||||
.headers
|
||||
.keys()
|
||||
.any(|name| name.eq_ignore_ascii_case("content-length")));
|
||||
assert_eq!(
|
||||
payload
|
||||
.client_body_json
|
||||
.as_ref()
|
||||
.and_then(|body| body.pointer("/error/message")),
|
||||
Some(&json!(
|
||||
"This content was flagged for possible cybersecurity risk."
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.client_body_json
|
||||
.as_ref()
|
||||
.and_then(|body| body.pointer("/error/code")),
|
||||
Some(&json!(400))
|
||||
);
|
||||
assert_ne!(payload.client_body_json.as_ref(), Some(&terminal_error));
|
||||
assert!(!trace_body.to_string().contains("AGENTS.md secret prompt"));
|
||||
|
||||
let raw_capture = payload
|
||||
.body_base64
|
||||
.as_deref()
|
||||
.and_then(|body| base64::engine::general_purpose::STANDARD.decode(body).ok())
|
||||
.expect("raw provider stream should remain available for usage auditing");
|
||||
assert_eq!(raw_capture, provider_buffered_body);
|
||||
assert!(String::from_utf8_lossy(&raw_capture).contains("AGENTS.md secret prompt"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +733,110 @@ async fn admin_monitoring_trace_request_exposes_failed_candidate_upstream_respon
|
||||
assert!(extra.get("provider_response").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_monitoring_trace_request_prefers_ref_backed_usage_response_body() {
|
||||
let mut candidate = sample_candidate(
|
||||
"cand-used",
|
||||
"request-ref-body",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(33),
|
||||
Some(400),
|
||||
);
|
||||
candidate.extra_data = Some(json!({
|
||||
"upstream_response": {
|
||||
"status_code": 400,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream",
|
||||
"x-request-id": "stale-request-like-body"
|
||||
},
|
||||
"body": {
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [{"role": "user", "content": "request prompt"}]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![candidate]));
|
||||
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider()],
|
||||
vec![sample_endpoint()],
|
||||
vec![sample_key()],
|
||||
));
|
||||
let mut usage = sample_usage(
|
||||
"request-ref-body",
|
||||
"provider-1",
|
||||
"OpenAI",
|
||||
0,
|
||||
0.0,
|
||||
"failed",
|
||||
Some(400),
|
||||
100,
|
||||
);
|
||||
usage.candidate_id = Some("cand-used".to_string());
|
||||
usage.response_headers = Some(json!({
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req_usage-cyber-risk-demo"
|
||||
}));
|
||||
usage.response_body = Some(json!({
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "This content was flagged for possible cybersecurity risk.",
|
||||
"code": 400
|
||||
}
|
||||
}));
|
||||
usage.response_body_state = Some(UsageBodyCaptureState::Reference);
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed_with_detached_bodies(
|
||||
vec![usage],
|
||||
));
|
||||
let data_state =
|
||||
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
|
||||
request_candidates,
|
||||
usage_repository,
|
||||
)
|
||||
.with_provider_catalog_reader(provider_catalog);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let context = request_context(
|
||||
http::Method::GET,
|
||||
"/api/admin/monitoring/trace/request-ref-body",
|
||||
);
|
||||
|
||||
let response = local_monitoring_response(&state, &context)
|
||||
.await
|
||||
.expect("handler should not error")
|
||||
.expect("route should be handled locally");
|
||||
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
||||
let upstream_response = &payload["candidates"][0]["extra_data"]["upstream_response"];
|
||||
assert_eq!(
|
||||
upstream_response["headers"],
|
||||
json!({
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req_usage-cyber-risk-demo"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_response["body"]["error"],
|
||||
json!({
|
||||
"type": "invalid_request",
|
||||
"message": "This content was flagged for possible cybersecurity risk.",
|
||||
"code": 400
|
||||
})
|
||||
);
|
||||
assert!(upstream_response["body"].get("input").is_none());
|
||||
assert_eq!(
|
||||
upstream_response["body_ref"],
|
||||
json!("usage://request/request-ref-body/response_body")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_monitoring_trace_request_decodes_connect_json_response_body_refs() {
|
||||
let mut candidate = sample_candidate(
|
||||
|
||||
@@ -30,6 +30,25 @@ struct ResolvedAdminMonitoringTrace {
|
||||
usage: Option<StoredRequestUsageAudit>,
|
||||
}
|
||||
|
||||
async fn hydrate_admin_monitoring_trace_response_body(
|
||||
state: &AdminAppState<'_>,
|
||||
mut usage: StoredRequestUsageAudit,
|
||||
) -> Result<StoredRequestUsageAudit, GatewayError> {
|
||||
let is_error_node = !usage.status.eq_ignore_ascii_case("completed")
|
||||
|| usage
|
||||
.status_code
|
||||
.is_some_and(|status| !(200..300).contains(&status));
|
||||
let response_body_ref = if is_error_node && usage.response_body.is_none() {
|
||||
usage.response_body_ref.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(body_ref) = response_body_ref.as_deref() {
|
||||
usage.response_body = state.resolve_request_usage_body_ref(body_ref).await?;
|
||||
}
|
||||
Ok(usage)
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_monitoring_trace_request_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -92,6 +111,10 @@ async fn resolve_admin_monitoring_trace(
|
||||
.read_request_usage_audit_shallow(request_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let usage = match usage {
|
||||
Some(usage) => Some(hydrate_admin_monitoring_trace_response_body(state, usage).await?),
|
||||
None => None,
|
||||
};
|
||||
return Ok(Some(ResolvedAdminMonitoringTrace { trace, usage }));
|
||||
}
|
||||
|
||||
@@ -102,9 +125,10 @@ async fn resolve_admin_monitoring_trace(
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
{
|
||||
usage_candidates.push(usage);
|
||||
usage_candidates.push(hydrate_admin_monitoring_trace_response_body(state, usage).await?);
|
||||
}
|
||||
if let Some(usage) = state.find_request_usage_by_id(request_id).await? {
|
||||
let usage = hydrate_admin_monitoring_trace_response_body(state, usage).await?;
|
||||
if !usage_candidates.iter().any(|item| item.id == usage.id) {
|
||||
usage_candidates.push(usage);
|
||||
}
|
||||
|
||||
+14
-4
@@ -106,7 +106,17 @@ fn reset_codex_cycle_usage_windows(status_snapshot: &mut Value, now_unix_secs: u
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !code.eq_ignore_ascii_case("5h") && !code.eq_ignore_ascii_case("weekly") {
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
let has_zero_window = window.get("window_minutes").and_then(Value::as_u64) == Some(0);
|
||||
if code.is_empty()
|
||||
|| !scope.eq_ignore_ascii_case("account")
|
||||
|| code.to_ascii_lowercase().starts_with("spark_")
|
||||
|| has_zero_window
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -177,7 +187,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(reset_codex_cycle_usage_windows(&mut snapshot, 1_234), 2);
|
||||
assert_eq!(reset_codex_cycle_usage_windows(&mut snapshot, 1_234), 3);
|
||||
let windows = snapshot["quota"]["windows"].as_array().expect("windows");
|
||||
assert_eq!(windows[0]["usage_reset_at"], json!(1_234));
|
||||
assert_eq!(windows[0]["usage"]["request_count"], json!(0));
|
||||
@@ -187,7 +197,7 @@ mod tests {
|
||||
assert_eq!(windows[1]["usage"]["request_count"], json!(0));
|
||||
assert_eq!(windows[1]["usage"]["total_tokens"], json!(0));
|
||||
assert_eq!(windows[1]["usage"]["total_cost_usd"], json!("0.00000000"));
|
||||
assert!(windows[2].get("usage_reset_at").is_none());
|
||||
assert!(windows[2].get("usage").is_some());
|
||||
assert_eq!(windows[2]["usage_reset_at"], json!(1_234));
|
||||
assert_eq!(windows[2]["usage"]["request_count"], json!(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,12 +255,25 @@ fn admin_pool_quota_window_reset_seconds(
|
||||
|
||||
fn admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||
window_code: &str,
|
||||
label: &str,
|
||||
window: &serde_json::Map<String, serde_json::Value>,
|
||||
now_unix_secs: u64,
|
||||
show_reset_without_consumption: bool,
|
||||
) -> Option<String> {
|
||||
let window = admin_pool_quota_window(quota_snapshot, window_code)?;
|
||||
if admin_pool_json_to_u64(window.get("window_minutes")) == Some(0) {
|
||||
return None;
|
||||
}
|
||||
let label = window
|
||||
.get("label")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|label| !label.is_empty())
|
||||
.or_else(|| {
|
||||
window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|code| !code.is_empty())
|
||||
})?;
|
||||
let used_percent = admin_pool_quota_window_used_percent(window)?;
|
||||
let reset_seconds =
|
||||
admin_pool_quota_window_reset_seconds(quota_snapshot, window, now_unix_secs);
|
||||
@@ -294,23 +307,35 @@ fn admin_pool_build_codex_account_quota_from_snapshot(
|
||||
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
"weekly",
|
||||
"周",
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
}
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
"5h",
|
||||
"5H",
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
if let Some(windows) = quota_snapshot
|
||||
.get("windows")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
{
|
||||
for window in windows.iter().filter_map(serde_json::Value::as_object) {
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
if !scope.eq_ignore_ascii_case("account")
|
||||
|| code.to_ascii_lowercase().starts_with("spark_")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
window,
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
@@ -345,6 +370,25 @@ fn admin_pool_current_unix_secs() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn admin_pool_is_regular_codex_cycle_window(
|
||||
window: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> bool {
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
!code.is_empty()
|
||||
&& scope.eq_ignore_ascii_case("account")
|
||||
&& !code.to_ascii_lowercase().starts_with("spark_")
|
||||
&& admin_pool_json_to_u64(window.get("window_minutes")) != Some(0)
|
||||
}
|
||||
|
||||
fn admin_pool_prune_expired_codex_window_usage_at(
|
||||
status_snapshot: &mut serde_json::Value,
|
||||
now_unix_secs: u64,
|
||||
@@ -362,12 +406,7 @@ fn admin_pool_prune_expired_codex_window_usage_at(
|
||||
.iter_mut()
|
||||
.filter_map(serde_json::Value::as_object_mut)
|
||||
{
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !code.eq_ignore_ascii_case("5h") && !code.eq_ignore_ascii_case("weekly") {
|
||||
if !admin_pool_is_regular_codex_cycle_window(window) {
|
||||
continue;
|
||||
}
|
||||
let Some(reset_at) = admin_pool_json_to_u64(window.get("reset_at")) else {
|
||||
@@ -1405,7 +1444,9 @@ mod tests {
|
||||
"quota": {
|
||||
"windows": [
|
||||
{
|
||||
"code": "5h",
|
||||
"code": "monthly",
|
||||
"scope": "account",
|
||||
"window_minutes": 43_800,
|
||||
"reset_at": 1,
|
||||
"usage": {
|
||||
"request_count": 7,
|
||||
@@ -1451,6 +1492,31 @@ mod tests {
|
||||
assert_eq!(usage["total_cost_usd"], json!("0.60000000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_monthly_quota_is_rendered_from_actual_snapshot_window() {
|
||||
let quota_snapshot = json!({
|
||||
"provider_type": "codex",
|
||||
"code": "ok",
|
||||
"exhausted": false,
|
||||
"windows": [
|
||||
{
|
||||
"code": "monthly",
|
||||
"label": "月",
|
||||
"scope": "account",
|
||||
"used_ratio": 0.14,
|
||||
"remaining_ratio": 0.86,
|
||||
"window_minutes": 43_800
|
||||
}
|
||||
]
|
||||
});
|
||||
let quota_snapshot = quota_snapshot.as_object().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
admin_pool_build_account_quota("codex", Some(quota_snapshot)),
|
||||
Some("月剩余 86.0%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_model_quota_is_rendered_for_pool_rows() {
|
||||
let quota_snapshot = json!({
|
||||
|
||||
@@ -50,6 +50,8 @@ fn admin_pool_codex_default_window_minutes(code: &str) -> Option<u64> {
|
||||
Some(300)
|
||||
} else if code.eq_ignore_ascii_case("weekly") {
|
||||
Some(10_080)
|
||||
} else if code.eq_ignore_ascii_case("monthly") {
|
||||
Some(43_800)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -98,16 +100,27 @@ fn admin_pool_codex_cycle_usage_request(
|
||||
window: &serde_json::Map<String, serde_json::Value>,
|
||||
now_unix_secs: u64,
|
||||
) -> Option<ProviderApiKeyWindowUsageRequest> {
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
if !scope.eq_ignore_ascii_case("account") {
|
||||
return None;
|
||||
}
|
||||
let window_code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|code| code.eq_ignore_ascii_case("5h") || code.eq_ignore_ascii_case("weekly"))?
|
||||
.filter(|code| !code.is_empty() && !code.to_ascii_lowercase().starts_with("spark_"))?
|
||||
.to_ascii_lowercase();
|
||||
let reset_at = admin_pool_json_u64(window.get("reset_at"))?;
|
||||
let window_seconds = admin_pool_json_u64(window.get("window_minutes"))
|
||||
.or_else(|| admin_pool_codex_default_window_minutes(&window_code))?
|
||||
.checked_mul(60)?;
|
||||
let window_minutes = match admin_pool_json_u64(window.get("window_minutes")) {
|
||||
Some(0) => return None,
|
||||
Some(value) => value,
|
||||
None => admin_pool_codex_default_window_minutes(&window_code)?,
|
||||
};
|
||||
let window_seconds = window_minutes.checked_mul(60)?;
|
||||
if reset_at <= now_unix_secs {
|
||||
return None;
|
||||
}
|
||||
@@ -788,6 +801,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cycle_usage_request_uses_actual_monthly_window_boundaries() {
|
||||
let key = sample_key("oauth");
|
||||
let reset_at = 5_000_000u64;
|
||||
let now = 3_000_000u64;
|
||||
let window = json!({
|
||||
"code": "monthly",
|
||||
"label": "月",
|
||||
"scope": "account",
|
||||
"reset_at": reset_at,
|
||||
"window_minutes": 43_800u64
|
||||
});
|
||||
|
||||
let request = admin_pool_codex_cycle_usage_request(
|
||||
&key,
|
||||
window.as_object().expect("window should be object"),
|
||||
now,
|
||||
)
|
||||
.expect("monthly usage request should build");
|
||||
|
||||
assert_eq!(request.window_code, "monthly");
|
||||
assert_eq!(request.start_unix_secs, reset_at - 43_800 * 60);
|
||||
assert_eq!(request.end_unix_secs, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cycle_usage_request_ignores_zero_and_spark_windows() {
|
||||
let key = sample_key("oauth");
|
||||
for window in [
|
||||
json!({
|
||||
"code": "weekly",
|
||||
"scope": "account",
|
||||
"reset_at": 5_000_000u64,
|
||||
"window_minutes": 0
|
||||
}),
|
||||
json!({
|
||||
"code": "spark_weekly",
|
||||
"scope": "account",
|
||||
"reset_at": 5_000_000u64,
|
||||
"window_minutes": 10_080
|
||||
}),
|
||||
] {
|
||||
assert!(admin_pool_codex_cycle_usage_request(
|
||||
&key,
|
||||
window.as_object().expect("window should be object"),
|
||||
3_000_000,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_status_filter_prefers_catalog_key_expiry_over_auth_config_expiry() {
|
||||
let mut key = sample_key("oauth");
|
||||
|
||||
@@ -35,13 +35,16 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
let Some(user_id) = admin_user_id_from_api_keys_path(request_context.path()) else {
|
||||
return Ok(build_admin_users_bad_request_response("缺少 user_id"));
|
||||
};
|
||||
if state.find_user_auth_by_id(&user_id).await?.is_none() {
|
||||
let Some(target_user) = state.find_user_auth_by_id(&user_id).await? else {
|
||||
return Ok((
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "用户不存在" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
// The authenticated principal authorizes this admin operation, but ownership and inherited
|
||||
// policies must always come from the user selected in the request path.
|
||||
let target_user_id = target_user.id;
|
||||
|
||||
let Some(request_body) = request_body else {
|
||||
return Ok((
|
||||
@@ -148,7 +151,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
|
||||
let Some(created) = state
|
||||
.create_user_api_key(aether_data::repository::auth::CreateUserApiKeyRecord {
|
||||
user_id: user_id.clone(),
|
||||
user_id: target_user_id.clone(),
|
||||
api_key_id: uuid::Uuid::new_v4().to_string(),
|
||||
key_hash: hash_admin_user_api_key(&plaintext_key),
|
||||
key_encrypted: Some(key_encrypted),
|
||||
@@ -174,7 +177,11 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
|
||||
let created = if allowed_providers.is_some() {
|
||||
match state
|
||||
.set_user_api_key_allowed_providers(&user_id, &created.api_key_id, allowed_providers)
|
||||
.set_user_api_key_allowed_providers(
|
||||
&target_user_id,
|
||||
&created.api_key_id,
|
||||
allowed_providers,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(updated) => updated,
|
||||
@@ -186,7 +193,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
let created = if feature_settings.is_some() {
|
||||
match state
|
||||
.set_user_api_key_feature_settings(
|
||||
&user_id,
|
||||
&target_user_id,
|
||||
&created.api_key_id,
|
||||
feature_settings.clone(),
|
||||
)
|
||||
|
||||
@@ -506,6 +506,9 @@ fn build_users_me_usage_record_payload(
|
||||
if let Some(reasoning_effort) = item.provider_reasoning_effort() {
|
||||
payload["reasoning_effort"] = json!(reasoning_effort);
|
||||
}
|
||||
if let Some(requested_reasoning_effort) = item.requested_reasoning_effort() {
|
||||
payload["requested_reasoning_effort"] = json!(requested_reasoning_effort);
|
||||
}
|
||||
if let Some(service_tier) = item.provider_service_tier() {
|
||||
payload["service_tier"] = json!(service_tier);
|
||||
}
|
||||
@@ -577,6 +580,9 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
|
||||
if let Some(reasoning_effort) = item.provider_reasoning_effort() {
|
||||
payload["reasoning_effort"] = json!(reasoning_effort);
|
||||
}
|
||||
if let Some(requested_reasoning_effort) = item.requested_reasoning_effort() {
|
||||
payload["requested_reasoning_effort"] = json!(requested_reasoning_effort);
|
||||
}
|
||||
if let Some(service_tier) = item.provider_service_tier() {
|
||||
payload["service_tier"] = json!(service_tier);
|
||||
}
|
||||
@@ -1655,6 +1661,27 @@ mod tests {
|
||||
assert_eq!(payload["cache_creation_ephemeral_1h_input_tokens"], 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_usage_payloads_expose_requested_and_provider_reasoning_mapping() {
|
||||
let item = StoredRequestUsageAudit {
|
||||
request_body: Some(json!({
|
||||
"reasoning": { "effort": "xhigh" }
|
||||
})),
|
||||
provider_request_body: Some(json!({
|
||||
"reasoning": { "effort": "max" }
|
||||
})),
|
||||
..sample_usage("completed")
|
||||
};
|
||||
|
||||
let record = build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false);
|
||||
let active = build_users_me_usage_active_payload(&item);
|
||||
|
||||
assert_eq!(record["requested_reasoning_effort"], "xhigh");
|
||||
assert_eq!(active["requested_reasoning_effort"], "xhigh");
|
||||
assert_eq!(record["reasoning_effort"], "max");
|
||||
assert_eq!(active["reasoning_effort"], "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_usage_active_override_uses_terminal_candidate_latency() {
|
||||
let candidate = sample_candidate(
|
||||
|
||||
@@ -806,6 +806,50 @@ fn codex_default_window_minutes(code: &str) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_quota_period_identity(window_minutes: u64) -> (String, String) {
|
||||
const MINUTES_PER_HOUR: u64 = 60;
|
||||
const MINUTES_PER_DAY: u64 = 24 * MINUTES_PER_HOUR;
|
||||
const MINUTES_PER_WEEK: u64 = 7 * MINUTES_PER_DAY;
|
||||
|
||||
if window_minutes == 5 * MINUTES_PER_HOUR {
|
||||
return ("5h".to_string(), "5H".to_string());
|
||||
}
|
||||
if window_minutes == MINUTES_PER_WEEK {
|
||||
return ("weekly".to_string(), "周".to_string());
|
||||
}
|
||||
if (28 * MINUTES_PER_DAY..=31 * MINUTES_PER_DAY).contains(&window_minutes) {
|
||||
return ("monthly".to_string(), "月".to_string());
|
||||
}
|
||||
|
||||
let label = if window_minutes % MINUTES_PER_WEEK == 0 {
|
||||
format!("{}周", window_minutes / MINUTES_PER_WEEK)
|
||||
} else if window_minutes % MINUTES_PER_DAY == 0 {
|
||||
format!("{}天", window_minutes / MINUTES_PER_DAY)
|
||||
} else if window_minutes % MINUTES_PER_HOUR == 0 {
|
||||
format!("{}H", window_minutes / MINUTES_PER_HOUR)
|
||||
} else {
|
||||
format!("{window_minutes}分钟")
|
||||
};
|
||||
(format!("window_{window_minutes}m"), label)
|
||||
}
|
||||
|
||||
fn codex_quota_window_identity(
|
||||
fallback_code: &str,
|
||||
fallback_label: &str,
|
||||
window_minutes: Option<u64>,
|
||||
) -> (String, String) {
|
||||
let Some(window_minutes) = window_minutes else {
|
||||
return (fallback_code.to_string(), fallback_label.to_string());
|
||||
};
|
||||
let is_spark = fallback_code.to_ascii_lowercase().starts_with("spark_");
|
||||
let (code, label) = codex_quota_period_identity(window_minutes);
|
||||
if is_spark {
|
||||
(format!("spark_{code}"), format!("Spark {label}"))
|
||||
} else {
|
||||
(code, label)
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_quota_window_snapshot(
|
||||
metadata: &Map<String, Value>,
|
||||
prefix: &str,
|
||||
@@ -847,6 +891,10 @@ fn codex_quota_window_snapshot(
|
||||
.get(&window_minutes_key)
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||
|
||||
if explicit_window_minutes == Some(0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if used_percent.is_none()
|
||||
&& reset_at.is_none()
|
||||
&& reset_seconds.is_none()
|
||||
@@ -856,6 +904,7 @@ fn codex_quota_window_snapshot(
|
||||
}
|
||||
|
||||
let window_minutes = explicit_window_minutes.or_else(|| codex_default_window_minutes(code));
|
||||
let (code, label) = codex_quota_window_identity(code, label, window_minutes);
|
||||
let used_ratio = used_percent.map(|value| (value / 100.0).clamp(0.0, 1.0));
|
||||
let remaining_ratio = used_ratio.map(|value| (1.0 - value).max(0.0));
|
||||
|
||||
@@ -931,12 +980,16 @@ fn build_codex_quota_status_snapshot(
|
||||
let primary_windows = windows
|
||||
.iter()
|
||||
.filter(|window| {
|
||||
window
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|code| {
|
||||
code.eq_ignore_ascii_case("weekly") || code.eq_ignore_ascii_case("5h")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
scope.eq_ignore_ascii_case("account")
|
||||
&& !code.to_ascii_lowercase().starts_with("spark_")
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
@@ -3792,6 +3845,39 @@ mod tests {
|
||||
assert_eq!(five_h.get("window_minutes"), Some(&json!(300u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_provider_key_quota_status_snapshot_labels_actual_monthly_window() {
|
||||
let upstream_metadata = json!({
|
||||
"codex": {
|
||||
"updated_at": 1_784_287_450u64,
|
||||
"plan_type": "team",
|
||||
"primary_used_percent": 14.0,
|
||||
"primary_reset_at": 1_786_915_122u64,
|
||||
"primary_window_minutes": 43_800u64,
|
||||
"secondary_used_percent": 0.0,
|
||||
"secondary_reset_after_seconds": 0u64,
|
||||
"secondary_window_minutes": 0u64
|
||||
}
|
||||
});
|
||||
|
||||
let payload = sync_provider_key_quota_status_snapshot(
|
||||
None,
|
||||
"codex",
|
||||
Some(&upstream_metadata),
|
||||
"response_headers",
|
||||
)
|
||||
.expect("quota snapshot should sync");
|
||||
let windows = payload["quota"]["windows"]
|
||||
.as_array()
|
||||
.expect("quota windows should exist");
|
||||
|
||||
assert_eq!(windows.len(), 1);
|
||||
assert_eq!(windows[0]["code"], json!("monthly"));
|
||||
assert_eq!(windows[0]["label"], json!("月"));
|
||||
assert_eq!(windows[0]["window_minutes"], json!(43_800u64));
|
||||
assert_eq!(windows[0]["remaining_ratio"], json!(0.86));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_status_snapshot_payload_backfills_thin_ok_snapshot_from_upstream_metadata() {
|
||||
let mut key = sample_catalog_key();
|
||||
|
||||
@@ -657,7 +657,8 @@ async fn gateway_executes_openai_responses_compact_as_unary_request_impl() {
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.body["prompt_cache_key"],
|
||||
json!("session:compact-e2e")
|
||||
// Compact omits client_metadata, but keeps the same deterministic Codex cache identity.
|
||||
json!("f3eb8726-b7b2-56c5-90b5-8789d628c8cf")
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.proxy_node_id,
|
||||
|
||||
@@ -1362,6 +1362,18 @@ async fn gateway_pool_list_overrides_stale_codex_cycle_usage_from_usage_facts()
|
||||
"total_tokens": 700,
|
||||
"total_cost_usd": "0.70000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "monthly",
|
||||
"label": "月",
|
||||
"scope": "account",
|
||||
"reset_at": reset_at,
|
||||
"window_minutes": 43_800,
|
||||
"usage": {
|
||||
"request_count": 8,
|
||||
"total_tokens": 600,
|
||||
"total_cost_usd": "0.60000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1441,6 +1453,10 @@ async fn gateway_pool_list_overrides_stale_codex_cycle_usage_from_usage_facts()
|
||||
.iter()
|
||||
.find(|window| window["code"] == json!("5h"))
|
||||
.expect("5h window should exist");
|
||||
let monthly = windows
|
||||
.iter()
|
||||
.find(|window| window["code"] == json!("monthly"))
|
||||
.expect("monthly window should exist");
|
||||
|
||||
assert_eq!(weekly["usage"]["request_count"], json!(3));
|
||||
assert_eq!(weekly["usage"]["total_tokens"], json!(2_199));
|
||||
@@ -1448,6 +1464,9 @@ async fn gateway_pool_list_overrides_stale_codex_cycle_usage_from_usage_facts()
|
||||
assert_eq!(five_hour["usage"]["request_count"], json!(1));
|
||||
assert_eq!(five_hour["usage"]["total_tokens"], json!(200));
|
||||
assert_eq!(five_hour["usage"]["total_cost_usd"], json!("0.75000000"));
|
||||
assert_eq!(monthly["usage"]["request_count"], json!(3));
|
||||
assert_eq!(monthly["usage"]["total_tokens"], json!(2_199));
|
||||
assert_eq!(monthly["usage"]["total_cost_usd"], json!("11.99000000"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -20,7 +20,7 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, issue_test_admin_access_token, start_server, AppState,
|
||||
build_router_with_state, hash_api_key, issue_test_admin_access_token, start_server, AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -1672,6 +1672,192 @@ async fn gateway_handles_admin_user_api_key_routes_locally_with_trusted_admin_pr
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_created_user_key_inherits_target_user_policy_not_admin_policy() {
|
||||
let mut admin_snapshot = sample_admin_api_key_snapshot("admin-user", "admin-seed-key");
|
||||
admin_snapshot.user_role = "admin".to_string();
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![
|
||||
(Some(hash_api_key("sk-admin-seed")), admin_snapshot),
|
||||
(
|
||||
Some(hash_api_key("sk-target-seed")),
|
||||
sample_admin_api_key_snapshot("target-user", "target-seed-key"),
|
||||
),
|
||||
]));
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_admin_user_with_role("admin-user", "admin", "admin@example.com", "admin"),
|
||||
sample_admin_user_with_role("target-user", "user", "target@example.com", "target"),
|
||||
]));
|
||||
|
||||
let admin_group = user_repository
|
||||
.create_user_group(UpsertUserGroupRecord {
|
||||
name: "Admin OpenAI".to_string(),
|
||||
description: None,
|
||||
priority: 10,
|
||||
allowed_providers: Some(vec!["openai".to_string()]),
|
||||
allowed_providers_mode: "specific".to_string(),
|
||||
allowed_api_formats: Some(vec!["openai:responses".to_string()]),
|
||||
allowed_api_formats_mode: "specific".to_string(),
|
||||
allowed_models: Some(vec!["gpt-5.4".to_string()]),
|
||||
allowed_models_mode: "specific".to_string(),
|
||||
rate_limit: Some(100),
|
||||
rate_limit_mode: "custom".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("admin group should create")
|
||||
.expect("admin group should exist");
|
||||
user_repository
|
||||
.add_user_to_group(&admin_group.id, "admin-user")
|
||||
.await
|
||||
.expect("admin group membership should create");
|
||||
|
||||
let target_group = user_repository
|
||||
.create_user_group(UpsertUserGroupRecord {
|
||||
name: "Target Claude".to_string(),
|
||||
description: None,
|
||||
priority: 10,
|
||||
allowed_providers: Some(vec!["anthropic".to_string()]),
|
||||
allowed_providers_mode: "specific".to_string(),
|
||||
allowed_api_formats: Some(vec!["claude:messages".to_string()]),
|
||||
allowed_api_formats_mode: "specific".to_string(),
|
||||
allowed_models: Some(vec!["claude-sonnet-4-5".to_string()]),
|
||||
allowed_models_mode: "specific".to_string(),
|
||||
rate_limit: Some(30),
|
||||
rate_limit_mode: "custom".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("target group should create")
|
||||
.expect("target group should exist");
|
||||
user_repository
|
||||
.add_user_to_group(&target_group.id, "target-user")
|
||||
.await
|
||||
.expect("target group membership should create");
|
||||
user_repository
|
||||
.update_user_feature_settings(
|
||||
"admin-user",
|
||||
Some(json!({"chat_pii_redaction": {"enabled": false}})),
|
||||
)
|
||||
.await
|
||||
.expect("admin feature settings should update");
|
||||
user_repository
|
||||
.update_user_feature_settings(
|
||||
"target-user",
|
||||
Some(json!({"chat_pii_redaction": {"enabled": true}})),
|
||||
)
|
||||
.await
|
||||
.expect("target feature settings should update");
|
||||
|
||||
let data_state = GatewayDataState::with_auth_api_key_repository_for_tests(auth_repository)
|
||||
.with_user_reader(user_repository);
|
||||
let app_state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let inspection_state = app_state.clone();
|
||||
let gateway = build_router_with_state(app_state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/users/target-user/api-keys"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "admin-session")
|
||||
.json(&json!({"name": "target-key"}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("response should parse");
|
||||
let plaintext_key = payload["key"].as_str().expect("plaintext key should exist");
|
||||
let api_key_id = payload["id"].as_str().expect("key id should exist");
|
||||
|
||||
let resolved = inspection_state
|
||||
.read_cached_auth_api_key_snapshot_by_key_hash(
|
||||
&hash_api_key(plaintext_key),
|
||||
chrono::Utc::now().timestamp().max(0) as u64,
|
||||
)
|
||||
.await
|
||||
.expect("created key snapshot should resolve")
|
||||
.expect("created key snapshot should exist");
|
||||
assert_eq!(resolved.user_id, "target-user");
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_providers(),
|
||||
Some(&["anthropic".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_api_formats(),
|
||||
Some(&["claude:messages".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_models(),
|
||||
Some(&["claude-sonnet-4-5".to_string()][..])
|
||||
);
|
||||
assert_eq!(resolved.user_rate_limit, Some(30));
|
||||
|
||||
inspection_state
|
||||
.update_user_group(
|
||||
&target_group.id,
|
||||
UpsertUserGroupRecord {
|
||||
name: "Target Gemini".to_string(),
|
||||
description: None,
|
||||
priority: 10,
|
||||
allowed_providers: Some(vec!["google".to_string()]),
|
||||
allowed_providers_mode: "specific".to_string(),
|
||||
allowed_api_formats: Some(vec!["gemini:generate-content".to_string()]),
|
||||
allowed_api_formats_mode: "specific".to_string(),
|
||||
allowed_models: Some(vec!["gemini-2.5-pro".to_string()]),
|
||||
allowed_models_mode: "specific".to_string(),
|
||||
rate_limit: Some(15),
|
||||
rate_limit_mode: "custom".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("target group should update")
|
||||
.expect("target group should still exist");
|
||||
let updated = inspection_state
|
||||
.read_cached_auth_api_key_snapshot_by_key_hash(
|
||||
&hash_api_key(plaintext_key),
|
||||
chrono::Utc::now().timestamp().max(0) as u64,
|
||||
)
|
||||
.await
|
||||
.expect("created key snapshot should refresh")
|
||||
.expect("created key snapshot should still exist");
|
||||
assert_eq!(updated.user_id, "target-user");
|
||||
assert_eq!(
|
||||
updated.effective_allowed_providers(),
|
||||
Some(&["google".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
updated.effective_allowed_api_formats(),
|
||||
Some(&["gemini:generate-content".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
updated.effective_allowed_models(),
|
||||
Some(&["gemini-2.5-pro".to_string()][..])
|
||||
);
|
||||
assert_eq!(updated.user_rate_limit, Some(15));
|
||||
|
||||
let target_features = inspection_state
|
||||
.read_user_feature_settings("target-user")
|
||||
.await
|
||||
.expect("target feature settings should resolve");
|
||||
let key_features = inspection_state
|
||||
.read_auth_api_key_feature_settings("target-user", api_key_id, false)
|
||||
.await
|
||||
.expect("key feature settings should resolve");
|
||||
assert_eq!(
|
||||
target_features,
|
||||
Some(json!({"chat_pii_redaction": {"enabled": true}}))
|
||||
);
|
||||
assert_eq!(
|
||||
key_features, None,
|
||||
"an omitted key override must inherit target settings"
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_conflict_for_admin_create_user_api_key_when_writer_unavailable() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -1231,6 +1231,9 @@ fn admin_usage_active_request_json(
|
||||
if let Some(reasoning_effort) = item.provider_reasoning_effort() {
|
||||
value["reasoning_effort"] = json!(reasoning_effort);
|
||||
}
|
||||
if let Some(requested_reasoning_effort) = item.requested_reasoning_effort() {
|
||||
value["requested_reasoning_effort"] = json!(requested_reasoning_effort);
|
||||
}
|
||||
if let Some(service_tier) = item.provider_service_tier() {
|
||||
value["service_tier"] = json!(service_tier);
|
||||
}
|
||||
@@ -1298,6 +1301,7 @@ pub fn admin_usage_record_json(
|
||||
"response_time_ms": item.response_time_ms,
|
||||
"first_byte_time_ms": item.first_byte_time_ms,
|
||||
"created_at": unix_secs_to_rfc3339(item.created_at_unix_ms),
|
||||
"updated_at": unix_secs_to_rfc3339(item.updated_at_unix_secs),
|
||||
"input_price_per_1m": input_price_per_1m,
|
||||
"output_price_per_1m": output_price_per_1m,
|
||||
"cache_creation_price_per_1m": cache_creation_price_per_1m,
|
||||
@@ -1354,6 +1358,12 @@ pub fn admin_usage_record_json(
|
||||
if let Some(reasoning_effort) = item.provider_reasoning_effort() {
|
||||
object.insert("reasoning_effort".to_string(), json!(reasoning_effort));
|
||||
}
|
||||
if let Some(requested_reasoning_effort) = item.requested_reasoning_effort() {
|
||||
object.insert(
|
||||
"requested_reasoning_effort".to_string(),
|
||||
json!(requested_reasoning_effort),
|
||||
);
|
||||
}
|
||||
if let Some(service_tier) = item.provider_service_tier() {
|
||||
object.insert("service_tier".to_string(), json!(service_tier));
|
||||
}
|
||||
@@ -2702,10 +2712,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_usage_record_includes_provider_reasoning_effort() {
|
||||
fn admin_usage_record_includes_requested_and_provider_reasoning_efforts() {
|
||||
let item = StoredRequestUsageAudit {
|
||||
request_body: Some(json!({
|
||||
"reasoning": { "effort": "xhigh" }
|
||||
})),
|
||||
provider_request_body: Some(json!({
|
||||
"reasoning": { "effort": "xhigh" },
|
||||
"reasoning": { "effort": "max" },
|
||||
"service_tier": "priority"
|
||||
})),
|
||||
..sample_usage("completed", Some(200), None)
|
||||
@@ -2721,10 +2734,14 @@ mod tests {
|
||||
);
|
||||
let active = admin_usage_active_request_json(&item, None, None, None);
|
||||
|
||||
assert_eq!(record["reasoning_effort"], "xhigh");
|
||||
assert_eq!(active["reasoning_effort"], "xhigh");
|
||||
assert_eq!(record["requested_reasoning_effort"], "xhigh");
|
||||
assert_eq!(active["requested_reasoning_effort"], "xhigh");
|
||||
assert_eq!(record["reasoning_effort"], "max");
|
||||
assert_eq!(active["reasoning_effort"], "max");
|
||||
assert_eq!(record["service_tier"], "priority");
|
||||
assert_eq!(active["service_tier"], "priority");
|
||||
assert!(record["updated_at"].is_string());
|
||||
assert_eq!(record["updated_at"], active["updated_at"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -630,6 +630,25 @@ fn codex_write_window(
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_window_has_active_limit(source: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
[
|
||||
"window_minutes",
|
||||
"limit_window_seconds",
|
||||
"reset_after_seconds",
|
||||
"reset_at",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
source
|
||||
.get(*key)
|
||||
.and_then(coerce_json_u64)
|
||||
.is_some_and(|value| value > 0)
|
||||
}) || source
|
||||
.get("used_percent")
|
||||
.and_then(coerce_json_f64)
|
||||
.is_some_and(|value| value > 0.0)
|
||||
}
|
||||
|
||||
fn codex_find_spark_rate_limit(
|
||||
root: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
@@ -719,7 +738,8 @@ pub fn parse_codex_wham_usage_response(
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let use_paid_windows = !secondary_window.is_empty() && plan_type.as_deref() != Some("free");
|
||||
let use_paid_windows =
|
||||
codex_window_has_active_limit(&secondary_window) && plan_type.as_deref() != Some("free");
|
||||
if use_paid_windows {
|
||||
codex_write_window(&mut result, &secondary_window, "primary");
|
||||
codex_write_window(&mut result, &primary_window, "secondary");
|
||||
@@ -1173,7 +1193,8 @@ pub fn parse_codex_usage_headers(
|
||||
|
||||
let primary_window = read_window("primary");
|
||||
let secondary_window = read_window("secondary");
|
||||
let use_paid_windows = !secondary_window.is_empty() && plan_type.as_deref() != Some("free");
|
||||
let use_paid_windows =
|
||||
codex_window_has_active_limit(&secondary_window) && plan_type.as_deref() != Some("free");
|
||||
if use_paid_windows {
|
||||
codex_write_window(&mut result, &secondary_window, "primary");
|
||||
codex_write_window(&mut result, &primary_window, "secondary");
|
||||
@@ -2091,8 +2112,8 @@ mod tests {
|
||||
codex_build_invalid_state, codex_runtime_invalid_reason,
|
||||
normalize_codex_reset_credit_consume_outcome, parse_antigravity_usage_response,
|
||||
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
|
||||
parse_codex_wham_reset_credits_detail_response, parse_codex_wham_usage_response,
|
||||
parse_gemini_cli_retrieve_user_quota_response,
|
||||
parse_codex_usage_headers, parse_codex_wham_reset_credits_detail_response,
|
||||
parse_codex_wham_usage_response, parse_gemini_cli_retrieve_user_quota_response,
|
||||
parse_gemini_cli_v1internal_credits_response, parse_windsurf_model_configs_response,
|
||||
parse_windsurf_rate_limit_response, parse_windsurf_user_status_response,
|
||||
provider_auto_remove_quota_exhausted_keys, quota_refresh_success_invalid_state,
|
||||
@@ -2101,6 +2122,7 @@ mod tests {
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn provider_auto_remove_quota_exhausted_keys_defaults_to_false() {
|
||||
@@ -2506,6 +2528,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_codex_monthly_header_without_zero_secondary_placeholder() {
|
||||
let headers = BTreeMap::from([
|
||||
("x-codex-plan-type".to_string(), "team".to_string()),
|
||||
("x-codex-primary-used-percent".to_string(), "14".to_string()),
|
||||
(
|
||||
"x-codex-primary-reset-after-seconds".to_string(),
|
||||
"2627672".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-reset-at".to_string(),
|
||||
"1786915122".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-window-minutes".to_string(),
|
||||
"43800".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-used-percent".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-reset-after-seconds".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
("x-codex-secondary-reset-at".to_string(), "".to_string()),
|
||||
(
|
||||
"x-codex-secondary-window-minutes".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
let parsed = parse_codex_usage_headers(&headers, 1_784_287_450)
|
||||
.expect("Codex usage headers should parse");
|
||||
|
||||
assert_eq!(parsed.get("primary_used_percent"), Some(&json!(14.0)));
|
||||
assert_eq!(
|
||||
parsed.get("primary_window_minutes"),
|
||||
Some(&json!(43_800u64))
|
||||
);
|
||||
assert!(parsed.get("secondary_used_percent").is_none());
|
||||
assert!(parsed.get("secondary_window_minutes").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_codex_reset_credit_count_from_wham_usage() {
|
||||
let parsed = parse_codex_wham_usage_response(
|
||||
|
||||
@@ -173,6 +173,7 @@ pub use crate::formats::{
|
||||
codex::{
|
||||
apply_codex_openai_compact_terminal_headers,
|
||||
apply_codex_openai_responses_chat_body_edits,
|
||||
apply_codex_openai_responses_identity_headers,
|
||||
apply_codex_openai_responses_lite_header_for_request_body_with_capabilities,
|
||||
apply_codex_openai_responses_lite_header_with_capabilities,
|
||||
apply_codex_openai_responses_special_body_edits,
|
||||
|
||||
@@ -7,6 +7,8 @@ use serde_json::{json, Value};
|
||||
|
||||
const CODEX_DEFAULT_REASONING_EFFORT: &str = "medium";
|
||||
const CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE: &str = "reasoning.encrypted_content";
|
||||
const CODEX_PROMPT_CACHE_IDENTITY_NAMESPACE: &str =
|
||||
"https://github.com/fawney19/Aether/codex/prompt-cache-identity/v1/";
|
||||
pub const CODEX_RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite";
|
||||
pub const CODEX_MODEL_CATALOG_METADATA_FIELD: &str = "codex_models";
|
||||
const CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS: &[&str] = &[
|
||||
@@ -1466,6 +1468,148 @@ fn wrap_codex_responses_string_input_for_backend(
|
||||
);
|
||||
}
|
||||
|
||||
fn non_empty_json_string(value: Option<&Value>) -> Option<&str> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn adapt_codex_prompt_cache_identity_for_backend(body_object: &mut serde_json::Map<String, Value>) {
|
||||
// The standard OpenAI contract permits arbitrary prompt_cache_key strings, while the Codex
|
||||
// backend's native session identity is UUID-shaped. Adapt only requests that carry a cache key
|
||||
// but do not already carry a native Codex session identity.
|
||||
let Some(prompt_cache_key) = body_object
|
||||
.get("prompt_cache_key")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
match body_object.get("client_metadata") {
|
||||
Some(Value::Object(metadata)) => {
|
||||
match metadata.get("session_id") {
|
||||
Some(Value::String(session_id)) if !session_id.trim().is_empty() => return,
|
||||
None | Some(Value::Null) => {}
|
||||
Some(_) => return,
|
||||
}
|
||||
match metadata.get("thread_id") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(thread_id)) if !thread_id.trim().is_empty() => {}
|
||||
Some(_) => return,
|
||||
}
|
||||
}
|
||||
Some(Value::Null) | None => {}
|
||||
Some(_) => return,
|
||||
}
|
||||
|
||||
let cache_identity = uuid::Uuid::parse_str(&prompt_cache_key)
|
||||
.unwrap_or_else(|_| {
|
||||
uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
format!("{CODEX_PROMPT_CACHE_IDENTITY_NAMESPACE}{prompt_cache_key}").as_bytes(),
|
||||
)
|
||||
})
|
||||
.to_string();
|
||||
|
||||
{
|
||||
let metadata = body_object
|
||||
.entry("client_metadata".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if metadata.is_null() {
|
||||
*metadata = json!({});
|
||||
}
|
||||
let Some(metadata) = metadata.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
metadata.insert(
|
||||
"session_id".to_string(),
|
||||
Value::String(cache_identity.clone()),
|
||||
);
|
||||
if metadata.get("thread_id").is_none_or(Value::is_null) {
|
||||
metadata.insert(
|
||||
"thread_id".to_string(),
|
||||
Value::String(cache_identity.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
body_object.insert(
|
||||
"prompt_cache_key".to_string(),
|
||||
Value::String(cache_identity),
|
||||
);
|
||||
}
|
||||
|
||||
fn valid_codex_identity_header(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.parse::<http::HeaderValue>().is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn codex_prompt_cache_header_identity(provider_request_body: &Value) -> Option<(String, String)> {
|
||||
let prompt_cache_key = non_empty_json_string(provider_request_body.get("prompt_cache_key"))?;
|
||||
|
||||
match provider_request_body.get("client_metadata") {
|
||||
Some(Value::Object(metadata)) => match metadata.get("session_id") {
|
||||
Some(Value::String(session_id)) if !session_id.trim().is_empty() => {
|
||||
let session_id = valid_codex_identity_header(session_id)?;
|
||||
let thread_id = match metadata.get("thread_id") {
|
||||
None | Some(Value::Null) => session_id.clone(),
|
||||
Some(Value::String(thread_id)) if !thread_id.trim().is_empty() => {
|
||||
valid_codex_identity_header(thread_id)?
|
||||
}
|
||||
Some(_) => return None,
|
||||
};
|
||||
return Some((session_id, thread_id));
|
||||
}
|
||||
None | Some(Value::Null) => {}
|
||||
Some(_) => return None,
|
||||
},
|
||||
Some(Value::Null) | None => {}
|
||||
Some(_) => return None,
|
||||
}
|
||||
|
||||
let cache_identity = uuid::Uuid::parse_str(prompt_cache_key).ok()?.to_string();
|
||||
Some((cache_identity.clone(), cache_identity))
|
||||
}
|
||||
|
||||
fn insert_btree_header_if_missing(
|
||||
headers: &mut BTreeMap<String, String>,
|
||||
header_name: &str,
|
||||
header_value: String,
|
||||
) {
|
||||
if headers.iter().any(|(name, value)| {
|
||||
name.trim().eq_ignore_ascii_case(header_name) && !value.trim().is_empty()
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
remove_btree_header(headers, header_name);
|
||||
headers.insert(header_name.to_string(), header_value);
|
||||
}
|
||||
|
||||
pub fn apply_codex_openai_responses_identity_headers(
|
||||
provider_request_headers: &mut BTreeMap<String, String>,
|
||||
provider_request_body: &Value,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) {
|
||||
// Codex projects its body session identity into compatibility HTTP headers. Existing explicit
|
||||
// headers remain authoritative; only missing values are completed here.
|
||||
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
|
||||
return;
|
||||
}
|
||||
let Some((session_id, thread_id)) = codex_prompt_cache_header_identity(provider_request_body)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
insert_btree_header_if_missing(provider_request_headers, "session-id", session_id);
|
||||
insert_btree_header_if_missing(provider_request_headers, "thread-id", thread_id);
|
||||
}
|
||||
|
||||
pub fn apply_codex_openai_responses_special_body_edits(
|
||||
provider_request_body: &mut Value,
|
||||
provider_type: &str,
|
||||
@@ -1532,6 +1676,7 @@ pub fn apply_codex_openai_responses_special_body_edits_with_source_model_and_cap
|
||||
body_object.remove(*field);
|
||||
}
|
||||
}
|
||||
adapt_codex_prompt_cache_identity_for_backend(body_object);
|
||||
if is_openai_responses_compact_request(provider_api_format) {
|
||||
body_object.remove("store");
|
||||
} else if !body_rules_handle_path(body_rules, "store") {
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::shared::stream_core::common::openai_stream_terminal_error_body;
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
|
||||
|
||||
@@ -719,6 +720,27 @@ fn extract_stream_error_event_body(body: &[u8]) -> Option<Value> {
|
||||
{
|
||||
return Some(normalize_provider_private_error_body(event));
|
||||
}
|
||||
if let Some(mut error_body) = openai_stream_terminal_error_body(&event) {
|
||||
let response_failed_without_provider_type = event
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|event_type| event_type.eq_ignore_ascii_case("response.failed"))
|
||||
&& event
|
||||
.pointer("/response/error/type")
|
||||
.or_else(|| event.pointer("/error/type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_none_or(str::is_empty);
|
||||
if response_failed_without_provider_type {
|
||||
if let Some(error) = error_body.get_mut("error").and_then(Value::as_object_mut) {
|
||||
error.insert(
|
||||
"type".to_string(),
|
||||
Value::String("stream_terminal_error".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Some(error_body);
|
||||
}
|
||||
current_event_type = None;
|
||||
}
|
||||
None
|
||||
@@ -1179,6 +1201,53 @@ data: {"message":"bad"}
|
||||
assert!(stream_body_contains_error_event(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_openai_response_failed_error_without_losing_provider_fields() {
|
||||
let body = br#"event: response.failed
|
||||
data: {"type":"response.failed","response":{"status":"failed","error":{"type":"invalid_request","message":"cyber policy rejected the request","code":"cyber_policy_violation","param":"input","details":{"policy_category":"cybersecurity","appeal_allowed":true}}}}
|
||||
|
||||
"#;
|
||||
|
||||
let error_body = extract_provider_private_stream_error_body(None, body)
|
||||
.expect("response.failed should expose its provider error body");
|
||||
|
||||
assert_eq!(
|
||||
error_body,
|
||||
json!({
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "cyber policy rejected the request",
|
||||
"code": "cyber_policy_violation",
|
||||
"param": "input",
|
||||
"details": {
|
||||
"policy_category": "cybersecurity",
|
||||
"appeal_allowed": true
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_failed_without_provider_type_uses_stream_terminal_error_category() {
|
||||
let body = br#"event: response.failed
|
||||
data: {"type":"response.failed","response":{"status":"failed","error":{"message":"cyber policy rejected the request","code":"cyber_policy"}}}
|
||||
|
||||
"#;
|
||||
|
||||
let error_body = extract_provider_private_stream_error_body(None, body)
|
||||
.expect("response.failed should expose its provider error body");
|
||||
|
||||
assert_eq!(
|
||||
error_body.pointer("/error/type"),
|
||||
Some(&json!("stream_terminal_error"))
|
||||
);
|
||||
assert_eq!(
|
||||
error_body.pointer("/error/code"),
|
||||
Some(&json!("cyber_policy"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_sync_error_message_is_not_normalized_as_success() {
|
||||
let report_context = json!({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
||||
use aether_data_contracts::repository::usage::{
|
||||
extract_provider_actual_service_tier_from_response,
|
||||
extract_provider_cache_ttl_minutes_from_metadata, extract_provider_service_tier_from_body,
|
||||
normalize_provider_service_tier, resolve_provider_cache_ttl_minutes,
|
||||
PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
extract_provider_cache_ttl_minutes_from_metadata, resolve_provider_cache_ttl_minutes,
|
||||
resolve_provider_service_tier_from_request_capture,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use aether_usage_runtime::{UsageEvent, UsageEventType};
|
||||
@@ -160,7 +158,9 @@ fn calculate_billing_computation(
|
||||
.clone()
|
||||
.or_else(|| event.data.api_format.clone()),
|
||||
requested_processing_tier: processing_tiers.requested,
|
||||
actual_processing_tier: processing_tiers.actual,
|
||||
// The response-reported tier remains usage audit data, but it is not authoritative for
|
||||
// pricing. Settlement follows the final request that was sent upstream.
|
||||
actual_processing_tier: None,
|
||||
request_count,
|
||||
input_tokens: event.data.input_tokens.unwrap_or_default() as i64,
|
||||
output_tokens: event.data.output_tokens.unwrap_or_default() as i64,
|
||||
@@ -192,29 +192,18 @@ fn calculate_billing_computation(
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct UsageEventProcessingTiers {
|
||||
requested: Option<String>,
|
||||
actual: Option<String>,
|
||||
}
|
||||
|
||||
fn usage_event_processing_tiers(
|
||||
data: &aether_usage_runtime::UsageEventData,
|
||||
) -> UsageEventProcessingTiers {
|
||||
let metadata = data.request_metadata.as_ref().and_then(Value::as_object);
|
||||
let requested = extract_provider_service_tier_from_body(data.provider_request_body.as_ref())
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|metadata| metadata.get(PROVIDER_SERVICE_TIER_METADATA_KEY))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
});
|
||||
let actual = metadata
|
||||
.and_then(|metadata| metadata.get(PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
.or_else(|| {
|
||||
extract_provider_actual_service_tier_from_response(data.response_body.as_ref())
|
||||
});
|
||||
let requested = resolve_provider_service_tier_from_request_capture(
|
||||
data.provider_request_body.as_ref(),
|
||||
data.provider_request_body_state,
|
||||
data.request_metadata.as_ref(),
|
||||
);
|
||||
|
||||
UsageEventProcessingTiers { requested, actual }
|
||||
UsageEventProcessingTiers { requested }
|
||||
}
|
||||
|
||||
fn usage_event_provider_cache_ttl_minutes(
|
||||
@@ -406,6 +395,7 @@ fn build_settlement_snapshot(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
||||
use aether_data_contracts::repository::usage::UsageBodyCaptureState;
|
||||
use aether_usage_runtime::{UsageEvent, UsageEventData, UsageEventType};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -444,7 +434,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_tier_facts_keep_request_and_terminal_response_independent() {
|
||||
fn processing_tier_fact_uses_final_provider_request_body() {
|
||||
let data = UsageEventData {
|
||||
provider_request_body: Some(json!({"service_tier": "Priority"})),
|
||||
response_body: Some(json!({"service_tier": "priority"})),
|
||||
@@ -458,7 +448,6 @@ mod tests {
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested.as_deref(), Some("priority"));
|
||||
assert_eq!(tiers.actual.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -474,7 +463,83 @@ mod tests {
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested.as_deref(), Some("fast"));
|
||||
assert_eq!(tiers.actual.as_deref(), Some("fast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_tier_does_not_fall_back_to_stale_metadata_when_body_is_present() {
|
||||
let data = UsageEventData {
|
||||
provider_request_body: Some(json!({"model": "gpt-5"})),
|
||||
response_body: Some(json!({"service_tier": "priority"})),
|
||||
request_metadata: Some(json!({
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "priority"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
};
|
||||
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_tier_uses_request_derived_metadata_when_body_capture_was_disabled() {
|
||||
let data = UsageEventData {
|
||||
provider_request_body: None,
|
||||
provider_request_body_state: Some(UsageBodyCaptureState::Disabled),
|
||||
response_body: Some(json!({"service_tier": "flex"})),
|
||||
request_metadata: Some(json!({
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "flex"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
};
|
||||
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested.as_deref(), Some("priority"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_tier_does_not_use_metadata_or_response_when_final_request_body_is_missing() {
|
||||
let data = UsageEventData {
|
||||
provider_request_body: None,
|
||||
provider_request_body_state: Some(UsageBodyCaptureState::None),
|
||||
response_body: Some(json!({"service_tier": "priority"})),
|
||||
request_metadata: Some(json!({
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "priority"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
};
|
||||
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_tier_uses_request_derived_metadata_when_body_capture_was_truncated() {
|
||||
let data = UsageEventData {
|
||||
provider_request_body: Some(json!({
|
||||
"truncated": true,
|
||||
"reason": "body_capture_limit_exceeded",
|
||||
"max_bytes": 128,
|
||||
"source_bytes": 4096,
|
||||
"value_kind": "object"
|
||||
})),
|
||||
provider_request_body_state: Some(UsageBodyCaptureState::Truncated),
|
||||
response_body: Some(json!({"service_tier": "default"})),
|
||||
request_metadata: Some(json!({
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "default"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
};
|
||||
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested.as_deref(), Some("priority"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -638,7 +703,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settlement_uses_actual_processing_tier_catalog_and_source() {
|
||||
async fn settlement_uses_requested_processing_tier_catalog_and_ignores_response_tier() {
|
||||
let lookup = TestLookup {
|
||||
name_context: Some(
|
||||
StoredBillingModelContext::new(
|
||||
@@ -706,13 +771,28 @@ mod tests {
|
||||
.and_then(|value| value.pointer("/settlement_snapshot/pricing_snapshot"))
|
||||
.expect("settlement pricing snapshot should exist");
|
||||
assert_eq!(pricing_snapshot["requested_processing_tier"], "priority");
|
||||
assert_eq!(pricing_snapshot["actual_processing_tier"], "flex");
|
||||
assert_eq!(pricing_snapshot["billing_processing_tier"], "flex");
|
||||
assert_eq!(pricing_snapshot["tiered_pricing_source"], "global_default");
|
||||
assert_eq!(pricing_snapshot["processing_tier_price_multiplier"], 0.5);
|
||||
assert!(pricing_snapshot["actual_processing_tier"].is_null());
|
||||
assert_eq!(pricing_snapshot["billing_processing_tier"], "priority");
|
||||
assert_eq!(
|
||||
pricing_snapshot["tiered_pricing_source"],
|
||||
"provider_override"
|
||||
);
|
||||
assert_eq!(
|
||||
pricing_snapshot["processing_tier_price_multiplier"],
|
||||
Value::Null
|
||||
);
|
||||
assert_eq!(
|
||||
pricing_snapshot["tiered_pricing"]["tiers"][0]["input_price_per_1m"],
|
||||
2.5
|
||||
10.0
|
||||
);
|
||||
// The response fact remains available for audit, but does not influence settlement.
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_actual_service_tier")),
|
||||
Some(&json!("flex"))
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
@@ -720,12 +800,12 @@ mod tests {
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| { value.pointer("/billing_dimensions/actual_processing_tier") }),
|
||||
Some(&json!("flex"))
|
||||
Some(&Value::Null)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_processing_catalog_controls_image_price_with_independent_fixed_price() {
|
||||
async fn requested_processing_catalog_controls_image_price_with_independent_fixed_price() {
|
||||
let lookup = TestLookup {
|
||||
name_context: Some(
|
||||
StoredBillingModelContext::new(
|
||||
@@ -783,27 +863,27 @@ mod tests {
|
||||
.await
|
||||
.expect("billing should succeed");
|
||||
|
||||
assert_eq!(event.data.total_cost_usd, Some(0.44));
|
||||
assert_eq!(event.data.actual_total_cost_usd, Some(0.44));
|
||||
assert_eq!(event.data.total_cost_usd, Some(0.84));
|
||||
assert_eq!(event.data.actual_total_cost_usd, Some(0.84));
|
||||
let metadata = event.data.request_metadata.as_ref().expect("metadata");
|
||||
let pricing = metadata
|
||||
.pointer("/settlement_snapshot/pricing_snapshot")
|
||||
.expect("pricing snapshot");
|
||||
assert_eq!(pricing["billing_processing_tier"], "flex");
|
||||
assert_eq!(pricing["tiered_pricing_source"], "global_default");
|
||||
assert_eq!(pricing["billing_processing_tier"], "priority");
|
||||
assert_eq!(pricing["tiered_pricing_source"], "provider_override");
|
||||
assert_eq!(pricing["price_per_request_source"], "provider_override");
|
||||
assert_eq!(pricing["pricing_source"], "mixed");
|
||||
assert_eq!(pricing["pricing_source"], "provider_override");
|
||||
assert_eq!(
|
||||
metadata
|
||||
.pointer("/billing_snapshot/resolved_variables/image_output_price_per_image")
|
||||
.and_then(Value::as_f64),
|
||||
Some(0.2)
|
||||
Some(0.4)
|
||||
);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.pointer("/billing_snapshot/cost_breakdown/image_output_cost")
|
||||
.and_then(Value::as_f64),
|
||||
Some(0.4)
|
||||
Some(0.8)
|
||||
);
|
||||
assert_eq!(
|
||||
metadata
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aether_data_contracts::repository::{
|
||||
billing::StoredBillingModelContext,
|
||||
global_models::{explicit_pricing_catalog_state, ExplicitPricingCatalogState},
|
||||
@@ -108,17 +106,12 @@ impl BillingModelPricingSnapshot {
|
||||
) -> BillingPricingResolution {
|
||||
let requested_processing_tier = normalize_processing_tier(requested_processing_tier);
|
||||
let actual_processing_tier = normalize_processing_tier(actual_processing_tier);
|
||||
let billing_processing_tier = actual_processing_tier
|
||||
// The provider-reported actual tier is retained in the resolution for audit only. The
|
||||
// final upstream request is the sole authority for selecting a billing catalog.
|
||||
let billing_processing_tier = requested_processing_tier
|
||||
.as_deref()
|
||||
.map(canonical_processing_tier)
|
||||
.or_else(|| {
|
||||
requested_processing_tier.as_deref().map_or(
|
||||
Some("standard".to_string()),
|
||||
|requested| {
|
||||
processing_tier_is_standard(requested).then(|| "standard".to_string())
|
||||
},
|
||||
)
|
||||
});
|
||||
.or_else(|| Some("standard".to_string()));
|
||||
|
||||
let (tiered_pricing, tiered_pricing_source, processing_tier_price_multiplier) =
|
||||
billing_processing_tier
|
||||
@@ -151,17 +144,12 @@ impl BillingModelPricingSnapshot {
|
||||
self.validate_processing_tier_containers()?;
|
||||
let requested_processing_tier = normalize_processing_tier(requested_processing_tier);
|
||||
let actual_processing_tier = normalize_processing_tier(actual_processing_tier);
|
||||
let billing_processing_tier = actual_processing_tier
|
||||
// Keep this checked path aligned with `resolve_pricing`: response facts must never choose
|
||||
// the catalog used for settlement.
|
||||
let billing_processing_tier = requested_processing_tier
|
||||
.as_deref()
|
||||
.map(canonical_processing_tier)
|
||||
.or_else(|| {
|
||||
requested_processing_tier.as_deref().map_or(
|
||||
Some("standard".to_string()),
|
||||
|requested| {
|
||||
processing_tier_is_standard(requested).then(|| "standard".to_string())
|
||||
},
|
||||
)
|
||||
});
|
||||
.or_else(|| Some("standard".to_string()));
|
||||
|
||||
let (tiered_pricing, tiered_pricing_source, processing_tier_price_multiplier) =
|
||||
match billing_processing_tier.as_deref() {
|
||||
@@ -208,34 +196,7 @@ impl BillingModelPricingSnapshot {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut billing_tiers = BTreeSet::from(["standard".to_string(), requested_billing_tier]);
|
||||
for pricing in [
|
||||
self.model_tiered_pricing.as_ref(),
|
||||
self.default_tiered_pricing.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(processing_tiers) = pricing.get("processing_tiers").and_then(Value::as_object)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
billing_tiers.extend(processing_tiers.keys().filter_map(|tier| {
|
||||
normalize_processing_tier(Some(tier)).map(|tier| canonical_processing_tier(&tier))
|
||||
}));
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for billing_tier in billing_tiers {
|
||||
let resolution = self.authorization_pricing_for_tier(
|
||||
requested_processing_tier.clone(),
|
||||
Some(billing_tier),
|
||||
)?;
|
||||
if resolution.bills_standard_processing_tier() || resolution.tiered_pricing.is_some() {
|
||||
candidates.push(resolution);
|
||||
}
|
||||
}
|
||||
Ok((!candidates.is_empty()).then_some(candidates))
|
||||
Ok(Some(vec![requested_resolution]))
|
||||
}
|
||||
|
||||
pub fn validate_authorization_pricing_configuration(
|
||||
@@ -839,7 +800,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_nonstandard_request_requires_actual_tier() {
|
||||
fn explicit_nonstandard_request_selects_requested_catalog_without_actual_tier() {
|
||||
let pricing = snapshot(
|
||||
None,
|
||||
Some(json!({
|
||||
@@ -852,17 +813,27 @@ mod tests {
|
||||
|
||||
let resolution = pricing.resolve_pricing(Some("Priority"), None);
|
||||
|
||||
assert!(resolution.requires_actual_processing_tier());
|
||||
assert!(!resolution.requires_actual_processing_tier());
|
||||
assert_eq!(
|
||||
resolution.requested_processing_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(resolution.billing_processing_tier, None);
|
||||
assert_eq!(resolution.tiered_pricing, None);
|
||||
assert_eq!(
|
||||
resolution.billing_processing_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(
|
||||
resolution
|
||||
.tiered_pricing
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/tiers/0/input_price_per_1m"))
|
||||
.and_then(serde_json::Value::as_f64),
|
||||
Some(6.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_tier_selects_exact_catalog_and_source() {
|
||||
fn response_actual_tier_is_audited_but_does_not_select_pricing_catalog() {
|
||||
let pricing = snapshot(
|
||||
Some(json!({
|
||||
"processing_tiers": {
|
||||
@@ -878,28 +849,29 @@ mod tests {
|
||||
);
|
||||
|
||||
let flex = pricing.resolve_pricing(Some("priority"), Some("flex"));
|
||||
assert_eq!(flex.billing_processing_tier.as_deref(), Some("flex"));
|
||||
assert_eq!(flex.actual_processing_tier.as_deref(), Some("flex"));
|
||||
assert_eq!(flex.billing_processing_tier.as_deref(), Some("priority"));
|
||||
assert_eq!(
|
||||
flex.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
Some(BillingPricingSource::ProviderOverride)
|
||||
);
|
||||
assert_eq!(
|
||||
flex.tiered_pricing
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/tiers/0/input_price_per_1m"))
|
||||
.and_then(serde_json::Value::as_f64),
|
||||
Some(1.5)
|
||||
Some(9.0)
|
||||
);
|
||||
|
||||
let standard = pricing.resolve_pricing(Some("priority"), Some("Default"));
|
||||
assert_eq!(standard.actual_processing_tier.as_deref(), Some("default"));
|
||||
assert_eq!(
|
||||
standard.billing_processing_tier.as_deref(),
|
||||
Some("standard")
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(
|
||||
standard.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
Some(BillingPricingSource::ProviderOverride)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -962,9 +934,7 @@ mod tests {
|
||||
.and_then(serde_json::Value::as_f64),
|
||||
Some(6.0)
|
||||
);
|
||||
assert!(candidates.iter().any(|resolution| {
|
||||
resolution.billing_processing_tier.as_deref() == Some("standard")
|
||||
}));
|
||||
assert_eq!(candidates.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1222,7 +1192,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_claude_fast_uses_the_exact_provider_multiplier_overlay() {
|
||||
fn requested_claude_fast_uses_the_exact_provider_multiplier_overlay() {
|
||||
let pricing = snapshot(
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 2.0}],
|
||||
@@ -1234,8 +1204,9 @@ mod tests {
|
||||
})),
|
||||
);
|
||||
|
||||
let resolved = pricing.resolve_pricing(Some("priority"), Some("fast"));
|
||||
let resolved = pricing.resolve_pricing(Some("fast"), Some("priority"));
|
||||
|
||||
assert_eq!(resolved.actual_processing_tier.as_deref(), Some("priority"));
|
||||
assert_eq!(resolved.billing_processing_tier.as_deref(), Some("fast"));
|
||||
assert_eq!(
|
||||
resolved
|
||||
|
||||
@@ -1104,7 +1104,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonstandard_request_without_actual_tier_fails_closed() {
|
||||
fn nonstandard_request_without_actual_tier_uses_requested_catalog() {
|
||||
let result = BillingService::new()
|
||||
.calculate(
|
||||
&processing_pricing(),
|
||||
@@ -1112,57 +1112,49 @@ mod tests {
|
||||
)
|
||||
.expect("billing should calculate");
|
||||
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::NoRule);
|
||||
assert_eq!(
|
||||
result.cost_result.snapshot.missing_required,
|
||||
vec!["actual_processing_tier"]
|
||||
);
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
|
||||
assert_eq!(
|
||||
result.cost_result.snapshot.resolved_dimensions["billing_processing_tier"],
|
||||
json!(null)
|
||||
json!("priority")
|
||||
);
|
||||
assert_eq!(
|
||||
result.cost_result.snapshot.resolved_variables["input_price_per_1m"],
|
||||
json!(10.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_tier_controls_standard_flex_and_priority_catalogs() {
|
||||
let cases = [
|
||||
(
|
||||
"default",
|
||||
100,
|
||||
5.0,
|
||||
6.25,
|
||||
BillingPricingSource::GlobalDefault,
|
||||
),
|
||||
("flex", 100, 2.5, 3.125, BillingPricingSource::GlobalDefault),
|
||||
(
|
||||
"priority",
|
||||
100,
|
||||
10.0,
|
||||
12.5,
|
||||
BillingPricingSource::ProviderOverride,
|
||||
),
|
||||
];
|
||||
fn response_actual_tier_does_not_override_requested_catalog() {
|
||||
let cases = ["default", "flex", "priority"];
|
||||
|
||||
for (actual, input_tokens, input_price, cache_write_price, source) in cases {
|
||||
for actual in cases {
|
||||
let result = BillingService::new()
|
||||
.calculate(
|
||||
&processing_pricing(),
|
||||
&processing_usage(Some("priority"), Some(actual), input_tokens),
|
||||
&processing_usage(Some("priority"), Some(actual), 100),
|
||||
)
|
||||
.expect("processing tier should resolve");
|
||||
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
|
||||
assert_eq!(
|
||||
result.pricing_resolution.actual_processing_tier.as_deref(),
|
||||
Some(actual)
|
||||
);
|
||||
assert_eq!(
|
||||
result.pricing_resolution.billing_processing_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(
|
||||
result.cost_result.snapshot.resolved_variables["input_price_per_1m"],
|
||||
json!(input_price)
|
||||
json!(10.0)
|
||||
);
|
||||
assert_eq!(
|
||||
result.cost_result.snapshot.resolved_variables["cache_creation_price_per_1m"],
|
||||
json!(cache_write_price)
|
||||
json!(12.5)
|
||||
);
|
||||
assert_eq!(
|
||||
result.pricing_resolution.tiered_pricing_source,
|
||||
Some(source)
|
||||
Some(BillingPricingSource::ProviderOverride)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1373,7 +1365,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_processing_catalog_and_unknown_actual_tier_fail_closed() {
|
||||
fn finite_processing_catalog_fails_only_on_requested_catalog_bounds() {
|
||||
let priority = BillingService::new()
|
||||
.calculate(
|
||||
&processing_pricing(),
|
||||
@@ -1386,16 +1378,22 @@ mod tests {
|
||||
vec!["input_context_tier"]
|
||||
);
|
||||
|
||||
let unknown = BillingService::new()
|
||||
let conflicting_actual = BillingService::new()
|
||||
.calculate(
|
||||
&processing_pricing(),
|
||||
&processing_usage(Some("priority"), Some("expedited"), 100),
|
||||
)
|
||||
.expect("billing should calculate");
|
||||
assert_eq!(unknown.cost_result.status, BillingSnapshotStatus::NoRule);
|
||||
assert_eq!(
|
||||
unknown.cost_result.snapshot.missing_required,
|
||||
vec!["processing_tier_catalog"]
|
||||
conflicting_actual.cost_result.status,
|
||||
BillingSnapshotStatus::Complete
|
||||
);
|
||||
assert_eq!(
|
||||
conflicting_actual
|
||||
.pricing_resolution
|
||||
.billing_processing_tier
|
||||
.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1502,13 +1500,17 @@ mod tests {
|
||||
estimate.max_output_tokens = Some(0);
|
||||
estimate.cache_ttl_minutes = Some(30);
|
||||
|
||||
for requested_processing_tier in [None, Some("standard"), Some("flex")] {
|
||||
for (requested_processing_tier, expected) in [
|
||||
(None, 3.75),
|
||||
(Some("standard"), 3.75),
|
||||
(Some("flex"), 1.875),
|
||||
] {
|
||||
estimate.requested_processing_tier = requested_processing_tier.map(ToOwned::to_owned);
|
||||
assert_eq!(
|
||||
service
|
||||
.estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate)
|
||||
.expect("eligible processing catalogs should calculate"),
|
||||
Some(3.75),
|
||||
Some(expected),
|
||||
"requested tier: {requested_processing_tier:?}"
|
||||
);
|
||||
}
|
||||
@@ -1523,7 +1525,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_actual_tier_cannot_fall_back_to_fixed_request_price() {
|
||||
fn unknown_actual_tier_does_not_override_requested_tier_or_fixed_price() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: Some(0.02),
|
||||
..processing_pricing()
|
||||
@@ -1535,16 +1537,20 @@ mod tests {
|
||||
)
|
||||
.expect("billing should calculate");
|
||||
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::NoRule);
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
|
||||
assert_eq!(
|
||||
result.cost_result.snapshot.missing_required,
|
||||
vec!["processing_tier_catalog"]
|
||||
result.pricing_resolution.billing_processing_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(
|
||||
result.pricing_resolution.actual_processing_tier.as_deref(),
|
||||
Some("expedited")
|
||||
);
|
||||
assert_eq!(result.pricing_resolution.price_per_request, Some(0.02));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_estimate_bounds_requested_and_provider_actual_catalogs() {
|
||||
fn authorization_estimate_bounds_only_the_requested_catalog() {
|
||||
let service = BillingService::new();
|
||||
let mut estimate = BillingAuthorizationEstimateInput::new("chat", 100_000);
|
||||
estimate.api_format = Some("openai:responses".to_string());
|
||||
@@ -1563,8 +1569,8 @@ mod tests {
|
||||
.expect("flex estimate should be bounded");
|
||||
|
||||
assert_eq!(priority, 61.25);
|
||||
assert_eq!(flex, 61.25);
|
||||
assert_eq!(priority, flex);
|
||||
assert_eq!(flex, 15.3125);
|
||||
assert!(priority > flex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -135,22 +135,22 @@ INSERT INTO `usage` (
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
api_key_id = VALUES(api_key_id),
|
||||
provider_name = VALUES(provider_name),
|
||||
model = VALUES(model),
|
||||
target_model = VALUES(target_model),
|
||||
provider_id = VALUES(provider_id),
|
||||
provider_endpoint_id = VALUES(provider_endpoint_id),
|
||||
provider_api_key_id = VALUES(provider_api_key_id),
|
||||
request_type = VALUES(request_type),
|
||||
api_format = VALUES(api_format),
|
||||
api_family = VALUES(api_family),
|
||||
endpoint_kind = VALUES(endpoint_kind),
|
||||
endpoint_api_format = VALUES(endpoint_api_format),
|
||||
provider_api_family = VALUES(provider_api_family),
|
||||
provider_endpoint_kind = VALUES(provider_endpoint_kind),
|
||||
has_format_conversion = VALUES(has_format_conversion),
|
||||
is_stream = VALUES(is_stream),
|
||||
upstream_is_stream = VALUES(upstream_is_stream),
|
||||
provider_name = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_name ELSE VALUES(provider_name) END,
|
||||
model = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN model ELSE VALUES(model) END,
|
||||
target_model = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN target_model ELSE VALUES(target_model) END,
|
||||
provider_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_id ELSE VALUES(provider_id) END,
|
||||
provider_endpoint_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_endpoint_id ELSE VALUES(provider_endpoint_id) END,
|
||||
provider_api_key_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_api_key_id ELSE VALUES(provider_api_key_id) END,
|
||||
request_type = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN request_type ELSE VALUES(request_type) END,
|
||||
api_format = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN api_format ELSE VALUES(api_format) END,
|
||||
api_family = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN api_family ELSE VALUES(api_family) END,
|
||||
endpoint_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN endpoint_kind ELSE VALUES(endpoint_kind) END,
|
||||
endpoint_api_format = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN endpoint_api_format ELSE VALUES(endpoint_api_format) END,
|
||||
provider_api_family = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_api_family ELSE VALUES(provider_api_family) END,
|
||||
provider_endpoint_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN provider_endpoint_kind ELSE VALUES(provider_endpoint_kind) END,
|
||||
has_format_conversion = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN has_format_conversion ELSE VALUES(has_format_conversion) END,
|
||||
is_stream = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN is_stream ELSE VALUES(is_stream) END,
|
||||
upstream_is_stream = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN upstream_is_stream ELSE VALUES(upstream_is_stream) END,
|
||||
input_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN input_tokens
|
||||
ELSE VALUES(input_tokens)
|
||||
@@ -231,15 +231,15 @@ ON DUPLICATE KEY UPDATE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN billing_status
|
||||
ELSE VALUES(billing_status)
|
||||
END,
|
||||
request_metadata = VALUES(request_metadata),
|
||||
candidate_id = VALUES(candidate_id),
|
||||
candidate_index = VALUES(candidate_index),
|
||||
key_name = VALUES(key_name),
|
||||
planner_kind = VALUES(planner_kind),
|
||||
route_family = VALUES(route_family),
|
||||
route_kind = VALUES(route_kind),
|
||||
execution_path = VALUES(execution_path),
|
||||
local_execution_runtime_miss_reason = VALUES(local_execution_runtime_miss_reason),
|
||||
request_metadata = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN request_metadata ELSE VALUES(request_metadata) END,
|
||||
candidate_id = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN candidate_id ELSE VALUES(candidate_id) END,
|
||||
candidate_index = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN candidate_index ELSE VALUES(candidate_index) END,
|
||||
key_name = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN key_name ELSE VALUES(key_name) END,
|
||||
planner_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN planner_kind ELSE VALUES(planner_kind) END,
|
||||
route_family = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN route_family ELSE VALUES(route_family) END,
|
||||
route_kind = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN route_kind ELSE VALUES(route_kind) END,
|
||||
execution_path = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN execution_path ELSE VALUES(execution_path) END,
|
||||
local_execution_runtime_miss_reason = CASE WHEN (status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')) OR (status = 'streaming' AND VALUES(status) = 'pending') THEN local_execution_runtime_miss_reason ELSE VALUES(local_execution_runtime_miss_reason) END,
|
||||
finalized_at = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN finalized_at
|
||||
ELSE VALUES(finalized_at)
|
||||
|
||||
@@ -65,6 +65,50 @@ fn mysql_usage_upsert_keeps_terminal_state_when_streaming_arrives_late() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_usage_upsert_guards_candidate_identity_metadata_and_routing_from_late_lifecycle() {
|
||||
for field in [
|
||||
"provider_name",
|
||||
"model",
|
||||
"target_model",
|
||||
"provider_id",
|
||||
"provider_endpoint_id",
|
||||
"provider_api_key_id",
|
||||
"request_type",
|
||||
"api_format",
|
||||
"api_family",
|
||||
"endpoint_kind",
|
||||
"endpoint_api_format",
|
||||
"provider_api_family",
|
||||
"provider_endpoint_kind",
|
||||
"has_format_conversion",
|
||||
"is_stream",
|
||||
"upstream_is_stream",
|
||||
"request_metadata",
|
||||
"candidate_id",
|
||||
"candidate_index",
|
||||
"key_name",
|
||||
"planner_kind",
|
||||
"route_family",
|
||||
"route_kind",
|
||||
"execution_path",
|
||||
"local_execution_runtime_miss_reason",
|
||||
] {
|
||||
let assignment = format!("{field} = CASE WHEN (");
|
||||
assert!(
|
||||
super::UPSERT_USAGE_SQL.contains(&assignment),
|
||||
"missing lifecycle guard for {field}"
|
||||
);
|
||||
let preserve = format!("THEN {field} ELSE VALUES({field}) END");
|
||||
assert!(
|
||||
super::UPSERT_USAGE_SQL.contains(&preserve),
|
||||
"late lifecycle must preserve {field}"
|
||||
);
|
||||
}
|
||||
assert!(super::UPSERT_USAGE_SQL
|
||||
.contains("OR (status = 'streaming' AND VALUES(status) = 'pending')"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mysql_usage_write_repository_upserts_when_url_is_set() {
|
||||
let Some(database_url) = std::env::var("AETHER_TEST_MYSQL_URL")
|
||||
|
||||
@@ -49,7 +49,9 @@ use aether_data_contracts::repository::usage::{
|
||||
StoredProviderUsageSummary, StoredRequestUsageAudit, StoredUsageDailySummary,
|
||||
UpsertUsageRecord, UsageAuditListQuery, UsageCounterFlushSummary, UsageCounterHealthSnapshot,
|
||||
UsageCounterPendingHealthSnapshot, UsageDailyHeatmapQuery, UsageReadRepository,
|
||||
UsageWriteRepository,
|
||||
UsageWriteRepository, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY,
|
||||
PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
REQUESTED_REASONING_EFFORT_METADATA_KEY,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
|
||||
@@ -7994,8 +7996,14 @@ ORDER BY "usage".user_id ASC
|
||||
http_audit_capture_mode,
|
||||
routing_snapshot,
|
||||
settlement_pricing_snapshot,
|
||||
request_metadata_value,
|
||||
request_metadata_json,
|
||||
mut request_metadata_value,
|
||||
mut request_metadata_json,
|
||||
replace_client_request_body_facts,
|
||||
replace_provider_request_body_facts,
|
||||
clear_request_body,
|
||||
clear_provider_request_body,
|
||||
clear_response_body,
|
||||
clear_client_response_body,
|
||||
} = prepared;
|
||||
Box::pin(async move {
|
||||
lock_usage_request_id_in_tx(tx, &usage.request_id).await?;
|
||||
@@ -8018,6 +8026,37 @@ ORDER BY "usage".user_id ASC
|
||||
|
||||
let previous_usage =
|
||||
find_usage_by_request_id_in_tx(tx, &usage.request_id).await?;
|
||||
let capture_update_allowed = usage_capture_update_allowed(
|
||||
previous_usage
|
||||
.as_ref()
|
||||
.map(|stored| (stored.status.as_str(), stored.billing_status.as_str())),
|
||||
usage.status.as_str(),
|
||||
);
|
||||
let replace_terminal_snapshots =
|
||||
matches!(usage.status.as_str(), "completed" | "failed" | "cancelled");
|
||||
if capture_update_allowed
|
||||
&& request_metadata_value.is_none()
|
||||
&& (replace_terminal_snapshots
|
||||
|| replace_client_request_body_facts
|
||||
|| replace_provider_request_body_facts)
|
||||
{
|
||||
let previous_metadata = previous_usage
|
||||
.as_ref()
|
||||
.and_then(|stored| stored.request_metadata.as_ref());
|
||||
request_metadata_value = Some(if replace_terminal_snapshots {
|
||||
retain_previous_request_audit_metadata(
|
||||
previous_metadata,
|
||||
!replace_client_request_body_facts,
|
||||
)
|
||||
} else {
|
||||
clear_previous_request_body_facts(
|
||||
previous_metadata,
|
||||
replace_client_request_body_facts,
|
||||
replace_provider_request_body_facts,
|
||||
)
|
||||
});
|
||||
request_metadata_json = json_bind_text(request_metadata_value.as_ref())?;
|
||||
}
|
||||
let _row = sqlx::query(UPSERT_SQL)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&usage.request_id)
|
||||
@@ -8090,72 +8129,89 @@ ORDER BY "usage".user_id ASC
|
||||
usage.updated_at_unix_secs
|
||||
))
|
||||
})?)
|
||||
.bind(request_body_storage.has_detached_blob())
|
||||
.bind(provider_request_body_storage.has_detached_blob())
|
||||
.bind(response_body_storage.has_detached_blob())
|
||||
.bind(client_response_body_storage.has_detached_blob())
|
||||
.bind(request_body_storage.has_detached_blob() || clear_request_body)
|
||||
.bind(
|
||||
provider_request_body_storage.has_detached_blob()
|
||||
|| clear_provider_request_body,
|
||||
)
|
||||
.bind(response_body_storage.has_detached_blob() || clear_response_body)
|
||||
.bind(
|
||||
client_response_body_storage.has_detached_blob()
|
||||
|| clear_client_response_body,
|
||||
)
|
||||
.bind(capture_update_allowed)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::RequestBody,
|
||||
usage.request_body.as_ref(),
|
||||
&request_body_storage,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
usage.provider_request_body.as_ref(),
|
||||
&provider_request_body_storage,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ResponseBody,
|
||||
usage.response_body.as_ref(),
|
||||
&response_body_storage,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
usage.client_response_body.as_ref(),
|
||||
&client_response_body_storage,
|
||||
)
|
||||
.await?;
|
||||
let http_audit_headers = UsageHttpAuditHeaders {
|
||||
request_headers_json: request_headers_json.as_deref(),
|
||||
provider_request_headers_json: provider_request_headers_json.as_deref(),
|
||||
response_headers_json: response_headers_json.as_deref(),
|
||||
client_response_headers_json: client_response_headers_json.as_deref(),
|
||||
};
|
||||
sync_usage_http_audit_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
&http_audit_headers,
|
||||
&http_audit_refs,
|
||||
&http_audit_states,
|
||||
http_audit_capture_mode,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_routing_snapshot_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
&routing_snapshot,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_settlement_pricing_snapshot_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
&settlement_pricing_snapshot,
|
||||
)
|
||||
.await?;
|
||||
if capture_update_allowed {
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::RequestBody,
|
||||
usage.request_body.as_ref(),
|
||||
&request_body_storage,
|
||||
clear_request_body,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
usage.provider_request_body.as_ref(),
|
||||
&provider_request_body_storage,
|
||||
clear_provider_request_body,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ResponseBody,
|
||||
usage.response_body.as_ref(),
|
||||
&response_body_storage,
|
||||
clear_response_body,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_body_blob_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
usage.client_response_body.as_ref(),
|
||||
&client_response_body_storage,
|
||||
clear_client_response_body,
|
||||
)
|
||||
.await?;
|
||||
let http_audit_headers = UsageHttpAuditHeaders {
|
||||
request_headers_json: request_headers_json.as_deref(),
|
||||
provider_request_headers_json: provider_request_headers_json.as_deref(),
|
||||
response_headers_json: response_headers_json.as_deref(),
|
||||
client_response_headers_json: client_response_headers_json.as_deref(),
|
||||
};
|
||||
sync_usage_http_audit_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
&http_audit_headers,
|
||||
&http_audit_refs,
|
||||
&http_audit_states,
|
||||
http_audit_capture_mode,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if capture_update_allowed {
|
||||
sync_usage_routing_snapshot_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
&routing_snapshot,
|
||||
replace_terminal_snapshots,
|
||||
)
|
||||
.await?;
|
||||
sync_usage_settlement_pricing_snapshot_storage(
|
||||
&mut **tx,
|
||||
&usage.request_id,
|
||||
&settlement_pricing_snapshot,
|
||||
replace_terminal_snapshots,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut stored = find_usage_by_request_id_in_tx(tx, &usage.request_id)
|
||||
.await?
|
||||
@@ -8165,71 +8221,79 @@ ORDER BY "usage".user_id ASC
|
||||
usage.request_id
|
||||
))
|
||||
})?;
|
||||
if request_body_storage.has_detached_blob() {
|
||||
stored.request_body = usage.request_body.clone();
|
||||
if capture_update_allowed {
|
||||
if request_body_storage.has_detached_blob() {
|
||||
stored.request_body = usage.request_body.clone();
|
||||
}
|
||||
stored.request_headers = usage.request_headers.clone();
|
||||
stored.provider_request_headers = usage.provider_request_headers.clone();
|
||||
if provider_request_body_storage.has_detached_blob() {
|
||||
stored.provider_request_body = usage.provider_request_body.clone();
|
||||
}
|
||||
stored.response_headers = usage.response_headers.clone();
|
||||
if response_body_storage.has_detached_blob() {
|
||||
stored.response_body = usage.response_body.clone();
|
||||
}
|
||||
stored.client_response_headers = usage.client_response_headers.clone();
|
||||
if client_response_body_storage.has_detached_blob() {
|
||||
stored.client_response_body = usage.client_response_body.clone();
|
||||
}
|
||||
stored.request_body_ref = if clear_request_body {
|
||||
None
|
||||
} else {
|
||||
resolved_write_usage_body_ref(
|
||||
usage.request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::RequestBody,
|
||||
request_body_storage.has_detached_blob(),
|
||||
http_audit_refs.request_body_ref.as_deref(),
|
||||
)
|
||||
};
|
||||
stored.provider_request_body_ref = if clear_provider_request_body {
|
||||
None
|
||||
} else {
|
||||
resolved_write_usage_body_ref(
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
provider_request_body_storage.has_detached_blob(),
|
||||
http_audit_refs.provider_request_body_ref.as_deref(),
|
||||
)
|
||||
};
|
||||
stored.response_body_ref = if clear_response_body {
|
||||
None
|
||||
} else {
|
||||
resolved_write_usage_body_ref(
|
||||
usage.response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ResponseBody,
|
||||
response_body_storage.has_detached_blob(),
|
||||
http_audit_refs.response_body_ref.as_deref(),
|
||||
)
|
||||
};
|
||||
stored.client_response_body_ref = if clear_client_response_body {
|
||||
None
|
||||
} else {
|
||||
resolved_write_usage_body_ref(
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
client_response_body_storage.has_detached_blob(),
|
||||
http_audit_refs.client_response_body_ref.as_deref(),
|
||||
)
|
||||
};
|
||||
stored.request_body_state =
|
||||
usage.request_body_state.or(stored.request_body_state);
|
||||
stored.provider_request_body_state = usage
|
||||
.provider_request_body_state
|
||||
.or(stored.provider_request_body_state);
|
||||
stored.response_body_state =
|
||||
usage.response_body_state.or(stored.response_body_state);
|
||||
stored.client_response_body_state = usage
|
||||
.client_response_body_state
|
||||
.or(stored.client_response_body_state);
|
||||
stored.request_metadata = request_metadata_value;
|
||||
}
|
||||
stored.request_headers = usage.request_headers.clone();
|
||||
stored.provider_request_headers = usage.provider_request_headers.clone();
|
||||
if provider_request_body_storage.has_detached_blob() {
|
||||
stored.provider_request_body = usage.provider_request_body.clone();
|
||||
}
|
||||
stored.response_headers = usage.response_headers.clone();
|
||||
if response_body_storage.has_detached_blob() {
|
||||
stored.response_body = usage.response_body.clone();
|
||||
}
|
||||
stored.client_response_headers = usage.client_response_headers.clone();
|
||||
if client_response_body_storage.has_detached_blob() {
|
||||
stored.client_response_body = usage.client_response_body.clone();
|
||||
}
|
||||
stored.request_body_ref = resolved_write_usage_body_ref(
|
||||
usage.request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::RequestBody,
|
||||
request_body_storage.has_detached_blob(),
|
||||
http_audit_refs.request_body_ref.as_deref(),
|
||||
);
|
||||
stored.provider_request_body_ref = resolved_write_usage_body_ref(
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
provider_request_body_storage.has_detached_blob(),
|
||||
http_audit_refs.provider_request_body_ref.as_deref(),
|
||||
);
|
||||
stored.response_body_ref = resolved_write_usage_body_ref(
|
||||
usage.response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ResponseBody,
|
||||
response_body_storage.has_detached_blob(),
|
||||
http_audit_refs.response_body_ref.as_deref(),
|
||||
);
|
||||
stored.client_response_body_ref = resolved_write_usage_body_ref(
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
client_response_body_storage.has_detached_blob(),
|
||||
http_audit_refs.client_response_body_ref.as_deref(),
|
||||
);
|
||||
stored.request_body_state =
|
||||
usage.request_body_state.or(stored.request_body_state);
|
||||
stored.provider_request_body_state = usage
|
||||
.provider_request_body_state
|
||||
.or(stored.provider_request_body_state);
|
||||
stored.response_body_state =
|
||||
usage.response_body_state.or(stored.response_body_state);
|
||||
stored.client_response_body_state = usage
|
||||
.client_response_body_state
|
||||
.or(stored.client_response_body_state);
|
||||
stored.candidate_id = routing_snapshot.candidate_id.clone();
|
||||
stored.candidate_index = routing_snapshot.candidate_index;
|
||||
stored.key_name = routing_snapshot.key_name.clone();
|
||||
stored.planner_kind = routing_snapshot.planner_kind.clone();
|
||||
stored.route_family = routing_snapshot.route_family.clone();
|
||||
stored.route_kind = routing_snapshot.route_kind.clone();
|
||||
stored.execution_path = routing_snapshot.execution_path.clone();
|
||||
stored.local_execution_runtime_miss_reason =
|
||||
routing_snapshot.local_execution_runtime_miss_reason.clone();
|
||||
stored.output_price_per_1m = settlement_pricing_snapshot.output_price_per_1m;
|
||||
stored.request_metadata = request_metadata_value;
|
||||
|
||||
let before_api_key_contribution =
|
||||
previous_usage.as_ref().and_then(api_key_usage_contribution);
|
||||
@@ -10415,6 +10479,112 @@ struct PreparedUsageUpsert {
|
||||
settlement_pricing_snapshot: UsageSettlementPricingSnapshot,
|
||||
request_metadata_value: Option<Value>,
|
||||
request_metadata_json: Option<String>,
|
||||
replace_client_request_body_facts: bool,
|
||||
replace_provider_request_body_facts: bool,
|
||||
clear_request_body: bool,
|
||||
clear_provider_request_body: bool,
|
||||
clear_response_body: bool,
|
||||
clear_client_response_body: bool,
|
||||
}
|
||||
|
||||
fn request_body_capture_replaces_derived_facts(
|
||||
request_body: Option<&Value>,
|
||||
request_body_state: Option<UsageBodyCaptureState>,
|
||||
) -> bool {
|
||||
// A typed capture state belongs to the incoming request snapshot. Metadata derived before a
|
||||
// body was externalized, truncated, disabled, or found unavailable is authoritative when
|
||||
// present; its absence must clear facts from an older candidate instead of falling through to
|
||||
// PostgreSQL's sparse-upsert COALESCE behavior.
|
||||
if request_body_state.is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(request_body) = request_body else {
|
||||
return false;
|
||||
};
|
||||
|
||||
!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")
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_previous_request_body_facts(
|
||||
previous_metadata: Option<&Value>,
|
||||
clear_client_request_body_facts: bool,
|
||||
clear_provider_request_body_facts: bool,
|
||||
) -> Value {
|
||||
let mut metadata = previous_metadata
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if clear_client_request_body_facts {
|
||||
metadata.remove(REQUESTED_REASONING_EFFORT_METADATA_KEY);
|
||||
metadata.remove("request_body_ref");
|
||||
}
|
||||
if clear_provider_request_body_facts {
|
||||
metadata.remove(PROVIDER_REASONING_EFFORT_METADATA_KEY);
|
||||
metadata.remove(PROVIDER_SERVICE_TIER_METADATA_KEY);
|
||||
metadata.remove(PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY);
|
||||
metadata.remove("provider_request_body_ref");
|
||||
}
|
||||
// Keep an explicit empty object as a tombstone. Binding SQL NULL here would make the upsert's
|
||||
// COALESCE retain the previous candidate's request-derived facts.
|
||||
Value::Object(metadata)
|
||||
}
|
||||
|
||||
fn retain_previous_request_audit_metadata(
|
||||
previous_metadata: Option<&Value>,
|
||||
preserve_client_request_body_facts: bool,
|
||||
) -> Value {
|
||||
let Some(previous_metadata) = previous_metadata.and_then(Value::as_object) else {
|
||||
return Value::Object(Map::new());
|
||||
};
|
||||
let mut retained = Map::new();
|
||||
for key in [
|
||||
"trace_id",
|
||||
"client_ip",
|
||||
"user_agent",
|
||||
"client_family",
|
||||
"client_requested_stream",
|
||||
"client_session_affinity",
|
||||
"api_key_is_standalone",
|
||||
"request_path",
|
||||
"request_query_string",
|
||||
"request_path_and_query",
|
||||
] {
|
||||
if let Some(value) = previous_metadata.get(key) {
|
||||
retained.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if preserve_client_request_body_facts {
|
||||
for key in [REQUESTED_REASONING_EFFORT_METADATA_KEY, "request_body_ref"] {
|
||||
if let Some(value) = previous_metadata.get(key) {
|
||||
retained.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(retained)
|
||||
}
|
||||
|
||||
fn usage_capture_update_allowed(
|
||||
previous_usage: Option<(&str, &str)>,
|
||||
incoming_status: &str,
|
||||
) -> bool {
|
||||
let Some((previous_status, previous_billing_status)) = previous_usage else {
|
||||
return true;
|
||||
};
|
||||
if previous_billing_status != "pending" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let previous_is_terminal = matches!(previous_status, "completed" | "failed" | "cancelled");
|
||||
let incoming_is_non_terminal = matches!(incoming_status, "pending" | "streaming");
|
||||
if previous_is_terminal && incoming_is_non_terminal {
|
||||
return false;
|
||||
}
|
||||
|
||||
!(previous_status == "streaming" && incoming_status == "pending")
|
||||
}
|
||||
|
||||
fn prepare_usage_body_storage(value: Option<&Value>) -> Result<UsageBodyStorage, DataLayerError> {
|
||||
@@ -10485,40 +10655,78 @@ fn json_bind_text(value: Option<&Value>) -> Result<Option<String>, DataLayerErro
|
||||
fn prepare_usage_upsert_context(
|
||||
usage: &UpsertUsageRecord,
|
||||
) -> Result<PreparedUsageUpsert, DataLayerError> {
|
||||
let replace_client_request_body_facts = request_body_capture_replaces_derived_facts(
|
||||
usage.request_body.as_ref(),
|
||||
usage.request_body_state,
|
||||
);
|
||||
let replace_provider_request_body_facts = request_body_capture_replaces_derived_facts(
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.provider_request_body_state,
|
||||
);
|
||||
let clear_request_body = usage.request_body_state == Some(UsageBodyCaptureState::None);
|
||||
let clear_provider_request_body =
|
||||
usage.provider_request_body_state == Some(UsageBodyCaptureState::None);
|
||||
let clear_response_body = usage.response_body_state == Some(UsageBodyCaptureState::None);
|
||||
let clear_client_response_body =
|
||||
usage.client_response_body_state == Some(UsageBodyCaptureState::None);
|
||||
// A typed `none` marker wins over residual values left on a reused event by an earlier
|
||||
// candidate. Do not serialize those values or recreate their detached references.
|
||||
let request_body = (!clear_request_body)
|
||||
.then_some(usage.request_body.as_ref())
|
||||
.flatten();
|
||||
let provider_request_body = (!clear_provider_request_body)
|
||||
.then_some(usage.provider_request_body.as_ref())
|
||||
.flatten();
|
||||
let response_body = (!clear_response_body)
|
||||
.then_some(usage.response_body.as_ref())
|
||||
.flatten();
|
||||
let client_response_body = (!clear_client_response_body)
|
||||
.then_some(usage.client_response_body.as_ref())
|
||||
.flatten();
|
||||
let request_body_ref = (!clear_request_body)
|
||||
.then_some(usage.request_body_ref.as_deref())
|
||||
.flatten();
|
||||
let provider_request_body_ref = (!clear_provider_request_body)
|
||||
.then_some(usage.provider_request_body_ref.as_deref())
|
||||
.flatten();
|
||||
let response_body_ref = (!clear_response_body)
|
||||
.then_some(usage.response_body_ref.as_deref())
|
||||
.flatten();
|
||||
let client_response_body_ref = (!clear_client_response_body)
|
||||
.then_some(usage.client_response_body_ref.as_deref())
|
||||
.flatten();
|
||||
let request_headers_json = json_bind_text(usage.request_headers.as_ref())?;
|
||||
let request_body_storage = prepare_usage_body_storage(usage.request_body.as_ref())?;
|
||||
let request_body_storage = prepare_usage_body_storage(request_body)?;
|
||||
let provider_request_headers_json = json_bind_text(usage.provider_request_headers.as_ref())?;
|
||||
let provider_request_body_storage =
|
||||
prepare_usage_body_storage(usage.provider_request_body.as_ref())?;
|
||||
let provider_request_body_storage = prepare_usage_body_storage(provider_request_body)?;
|
||||
let response_headers_json = json_bind_text(usage.response_headers.as_ref())?;
|
||||
let response_body_storage = prepare_usage_body_storage(usage.response_body.as_ref())?;
|
||||
let response_body_storage = prepare_usage_body_storage(response_body)?;
|
||||
let client_response_headers_json = json_bind_text(usage.client_response_headers.as_ref())?;
|
||||
let client_response_body_storage =
|
||||
prepare_usage_body_storage(usage.client_response_body.as_ref())?;
|
||||
let client_response_body_storage = prepare_usage_body_storage(client_response_body)?;
|
||||
let http_audit_refs = UsageHttpAuditRefs {
|
||||
request_body_ref: resolved_write_usage_body_ref(
|
||||
usage.request_body_ref.as_deref(),
|
||||
request_body_ref,
|
||||
&usage.request_id,
|
||||
UsageBodyField::RequestBody,
|
||||
request_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
provider_request_body_ref: resolved_write_usage_body_ref(
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
provider_request_body_ref,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
provider_request_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
response_body_ref: resolved_write_usage_body_ref(
|
||||
usage.response_body_ref.as_deref(),
|
||||
response_body_ref,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ResponseBody,
|
||||
response_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
client_response_body_ref: resolved_write_usage_body_ref(
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
client_response_body_ref,
|
||||
&usage.request_id,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
client_response_body_storage.has_detached_blob(),
|
||||
@@ -10547,42 +10755,49 @@ fn prepare_usage_upsert_context(
|
||||
http_audit_refs.client_response_body_ref.as_deref(),
|
||||
),
|
||||
};
|
||||
let request_metadata_value = prepare_request_metadata_for_body_storage(
|
||||
let mut request_metadata_value = prepare_request_metadata_for_body_storage(
|
||||
usage.request_metadata.clone(),
|
||||
[
|
||||
(
|
||||
UsageBodyField::RequestBody,
|
||||
&request_body_storage,
|
||||
usage.request_body.as_ref(),
|
||||
usage.request_body_ref.as_deref(),
|
||||
request_body,
|
||||
request_body_ref,
|
||||
),
|
||||
(
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
&provider_request_body_storage,
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
provider_request_body,
|
||||
provider_request_body_ref,
|
||||
),
|
||||
(
|
||||
UsageBodyField::ResponseBody,
|
||||
&response_body_storage,
|
||||
usage.response_body.as_ref(),
|
||||
usage.response_body_ref.as_deref(),
|
||||
response_body,
|
||||
response_body_ref,
|
||||
),
|
||||
(
|
||||
UsageBodyField::ClientResponseBody,
|
||||
&client_response_body_storage,
|
||||
usage.client_response_body.as_ref(),
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
client_response_body,
|
||||
client_response_body_ref,
|
||||
),
|
||||
],
|
||||
);
|
||||
if request_metadata_value.is_some() && (clear_request_body || clear_provider_request_body) {
|
||||
request_metadata_value = Some(clear_previous_request_body_facts(
|
||||
request_metadata_value.as_ref(),
|
||||
clear_request_body,
|
||||
clear_provider_request_body,
|
||||
));
|
||||
}
|
||||
let http_audit_capture_mode = usage_http_audit_capture_mode(
|
||||
&http_audit_refs,
|
||||
[
|
||||
usage.request_body.as_ref(),
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.response_body.as_ref(),
|
||||
usage.client_response_body.as_ref(),
|
||||
request_body,
|
||||
provider_request_body,
|
||||
response_body,
|
||||
client_response_body,
|
||||
],
|
||||
);
|
||||
let routing_snapshot =
|
||||
@@ -10607,6 +10822,12 @@ fn prepare_usage_upsert_context(
|
||||
settlement_pricing_snapshot,
|
||||
request_metadata_value,
|
||||
request_metadata_json,
|
||||
replace_client_request_body_facts,
|
||||
replace_provider_request_body_facts,
|
||||
clear_request_body,
|
||||
clear_provider_request_body,
|
||||
clear_response_body,
|
||||
clear_client_response_body,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11442,11 +11663,20 @@ async fn sync_usage_body_blob_storage<'e, E>(
|
||||
field: UsageBodyField,
|
||||
value: Option<&Value>,
|
||||
storage: &UsageBodyStorage,
|
||||
clear_existing: bool,
|
||||
) -> Result<(), DataLayerError>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = Postgres>,
|
||||
{
|
||||
let body_ref = usage_body_ref(request_id, field);
|
||||
if clear_existing {
|
||||
sqlx::query(DELETE_USAGE_BODY_BLOB_SQL)
|
||||
.bind(&body_ref)
|
||||
.execute(executor)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(payload_gzip) = storage.detached_blob_bytes.as_ref() {
|
||||
sqlx::query(UPSERT_USAGE_BODY_BLOB_SQL)
|
||||
.bind(&body_ref)
|
||||
@@ -11523,11 +11753,12 @@ async fn sync_usage_routing_snapshot_storage<'e, E>(
|
||||
executor: E,
|
||||
request_id: &str,
|
||||
snapshot: &UsageRoutingSnapshot,
|
||||
replace_existing: bool,
|
||||
) -> Result<(), DataLayerError>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = Postgres>,
|
||||
{
|
||||
if !snapshot.any_present() {
|
||||
if !snapshot.any_present() && !replace_existing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -11545,6 +11776,7 @@ where
|
||||
.bind(snapshot.selected_endpoint_id.as_deref())
|
||||
.bind(snapshot.selected_provider_api_key_id.as_deref())
|
||||
.bind(snapshot.has_format_conversion)
|
||||
.bind(replace_existing)
|
||||
.execute(executor)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
@@ -11556,11 +11788,12 @@ async fn sync_usage_settlement_pricing_snapshot_storage<'e, E>(
|
||||
executor: E,
|
||||
request_id: &str,
|
||||
snapshot: &UsageSettlementPricingSnapshot,
|
||||
replace_existing: bool,
|
||||
) -> Result<(), DataLayerError>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = Postgres>,
|
||||
{
|
||||
if !snapshot.any_present() {
|
||||
if !snapshot.any_present() && !replace_existing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -11594,6 +11827,7 @@ where
|
||||
.bind(snapshot.cache_creation_price_per_1m)
|
||||
.bind(snapshot.cache_read_price_per_1m)
|
||||
.bind(snapshot.price_per_request)
|
||||
.bind(replace_existing)
|
||||
.execute(executor)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
+3
@@ -179,6 +179,7 @@ SELECT
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'user_agent'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'request_path'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'request_path_and_query'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'requested_reasoning_effort'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), '') IS NOT NULL
|
||||
@@ -193,6 +194,8 @@ SELECT
|
||||
NULLIF(BTRIM("usage".request_metadata->>'request_path'), ''),
|
||||
'request_path_and_query',
|
||||
NULLIF(BTRIM("usage".request_metadata->>'request_path_and_query'), ''),
|
||||
'requested_reasoning_effort',
|
||||
NULLIF(BTRIM("usage".request_metadata->>'requested_reasoning_effort'), ''),
|
||||
'provider_reasoning_effort',
|
||||
NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), ''),
|
||||
'provider_service_tier',
|
||||
|
||||
@@ -179,6 +179,7 @@ SELECT
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'user_agent'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'request_path'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'request_path_and_query'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'requested_reasoning_effort'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), '') IS NOT NULL
|
||||
OR NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), '') IS NOT NULL
|
||||
@@ -193,6 +194,8 @@ SELECT
|
||||
NULLIF(BTRIM("usage".request_metadata->>'request_path'), ''),
|
||||
'request_path_and_query',
|
||||
NULLIF(BTRIM("usage".request_metadata->>'request_path_and_query'), ''),
|
||||
'requested_reasoning_effort',
|
||||
NULLIF(BTRIM("usage".request_metadata->>'requested_reasoning_effort'), ''),
|
||||
'provider_reasoning_effort',
|
||||
NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), ''),
|
||||
'provider_service_tier',
|
||||
|
||||
@@ -157,22 +157,22 @@ DO UPDATE SET
|
||||
api_key_id = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.api_key_id, "usage".api_key_id) ELSE "usage".api_key_id END,
|
||||
username = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.username, "usage".username) ELSE "usage".username END,
|
||||
api_key_name = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.api_key_name, "usage".api_key_name) ELSE "usage".api_key_name END,
|
||||
provider_name = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.provider_name, "usage".provider_name) ELSE "usage".provider_name END,
|
||||
model = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.model, "usage".model) ELSE "usage".model END,
|
||||
target_model = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.target_model, "usage".target_model) ELSE "usage".target_model END,
|
||||
provider_id = CASE WHEN "usage".billing_status = 'pending' OR ("usage".provider_id IS NULL AND ("usage".provider_endpoint_id IS NULL OR "usage".provider_endpoint_id = EXCLUDED.provider_endpoint_id) AND ("usage".provider_api_key_id IS NULL OR "usage".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_id, "usage".provider_id) ELSE "usage".provider_id END,
|
||||
provider_endpoint_id = CASE WHEN "usage".billing_status = 'pending' OR ("usage".provider_endpoint_id IS NULL AND ("usage".provider_id IS NULL OR "usage".provider_id = EXCLUDED.provider_id) AND ("usage".provider_api_key_id IS NULL OR "usage".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_endpoint_id, "usage".provider_endpoint_id) ELSE "usage".provider_endpoint_id END,
|
||||
provider_api_key_id = CASE WHEN "usage".billing_status = 'pending' OR ("usage".provider_api_key_id IS NULL AND ("usage".provider_id IS NULL OR "usage".provider_id = EXCLUDED.provider_id) AND ("usage".provider_endpoint_id IS NULL OR "usage".provider_endpoint_id = EXCLUDED.provider_endpoint_id)) THEN COALESCE(EXCLUDED.provider_api_key_id, "usage".provider_api_key_id) ELSE "usage".provider_api_key_id END,
|
||||
request_type = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.request_type, "usage".request_type) ELSE "usage".request_type END,
|
||||
api_format = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.api_format, "usage".api_format) ELSE "usage".api_format END,
|
||||
api_family = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.api_family, "usage".api_family) ELSE "usage".api_family END,
|
||||
endpoint_kind = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.endpoint_kind, "usage".endpoint_kind) ELSE "usage".endpoint_kind END,
|
||||
endpoint_api_format = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.endpoint_api_format, "usage".endpoint_api_format) ELSE "usage".endpoint_api_format END,
|
||||
provider_api_family = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.provider_api_family, "usage".provider_api_family) ELSE "usage".provider_api_family END,
|
||||
provider_endpoint_kind = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.provider_endpoint_kind, "usage".provider_endpoint_kind) ELSE "usage".provider_endpoint_kind END,
|
||||
has_format_conversion = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.has_format_conversion, "usage".has_format_conversion) ELSE "usage".has_format_conversion END,
|
||||
is_stream = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.is_stream, "usage".is_stream) ELSE "usage".is_stream END,
|
||||
upstream_is_stream = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.upstream_is_stream, "usage".upstream_is_stream, "usage".is_stream, false) ELSE "usage".upstream_is_stream END,
|
||||
provider_name = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.provider_name, "usage".provider_name) ELSE "usage".provider_name END,
|
||||
model = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.model, "usage".model) ELSE "usage".model END,
|
||||
target_model = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE WHEN EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN EXCLUDED.target_model ELSE COALESCE(EXCLUDED.target_model, "usage".target_model) END ELSE "usage".target_model END,
|
||||
provider_id = CASE WHEN ("usage".billing_status = 'pending' AND $61) OR ("usage".billing_status <> 'pending' AND "usage".provider_id IS NULL AND ("usage".provider_endpoint_id IS NULL OR "usage".provider_endpoint_id = EXCLUDED.provider_endpoint_id) AND ("usage".provider_api_key_id IS NULL OR "usage".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_id, "usage".provider_id) ELSE "usage".provider_id END,
|
||||
provider_endpoint_id = CASE WHEN ("usage".billing_status = 'pending' AND $61) OR ("usage".billing_status <> 'pending' AND "usage".provider_endpoint_id IS NULL AND ("usage".provider_id IS NULL OR "usage".provider_id = EXCLUDED.provider_id) AND ("usage".provider_api_key_id IS NULL OR "usage".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_endpoint_id, "usage".provider_endpoint_id) ELSE "usage".provider_endpoint_id END,
|
||||
provider_api_key_id = CASE WHEN ("usage".billing_status = 'pending' AND $61) OR ("usage".billing_status <> 'pending' AND "usage".provider_api_key_id IS NULL AND ("usage".provider_id IS NULL OR "usage".provider_id = EXCLUDED.provider_id) AND ("usage".provider_endpoint_id IS NULL OR "usage".provider_endpoint_id = EXCLUDED.provider_endpoint_id)) THEN COALESCE(EXCLUDED.provider_api_key_id, "usage".provider_api_key_id) ELSE "usage".provider_api_key_id END,
|
||||
request_type = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.request_type, "usage".request_type) ELSE "usage".request_type END,
|
||||
api_format = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.api_format, "usage".api_format) ELSE "usage".api_format END,
|
||||
api_family = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.api_family, "usage".api_family) ELSE "usage".api_family END,
|
||||
endpoint_kind = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.endpoint_kind, "usage".endpoint_kind) ELSE "usage".endpoint_kind END,
|
||||
endpoint_api_format = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.endpoint_api_format, "usage".endpoint_api_format) ELSE "usage".endpoint_api_format END,
|
||||
provider_api_family = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.provider_api_family, "usage".provider_api_family) ELSE "usage".provider_api_family END,
|
||||
provider_endpoint_kind = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.provider_endpoint_kind, "usage".provider_endpoint_kind) ELSE "usage".provider_endpoint_kind END,
|
||||
has_format_conversion = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.has_format_conversion, "usage".has_format_conversion) ELSE "usage".has_format_conversion END,
|
||||
is_stream = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.is_stream, "usage".is_stream) ELSE "usage".is_stream END,
|
||||
upstream_is_stream = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.upstream_is_stream, "usage".upstream_is_stream, "usage".is_stream, false) ELSE "usage".upstream_is_stream END,
|
||||
input_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".input_tokens, EXCLUDED.input_tokens) ELSE "usage".input_tokens END,
|
||||
output_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".output_tokens, EXCLUDED.output_tokens) ELSE "usage".output_tokens END,
|
||||
total_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".total_tokens, EXCLUDED.total_tokens) ELSE "usage".total_tokens END,
|
||||
@@ -223,42 +223,42 @@ DO UPDATE SET
|
||||
END ELSE "usage".status END,
|
||||
billing_status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.billing_status ELSE "usage".billing_status END,
|
||||
request_headers = NULL,
|
||||
request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
request_body = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.request_body_compressed IS NOT NULL OR $57 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.request_body, "usage".request_body)
|
||||
END ELSE "usage".request_body END,
|
||||
request_body_compressed = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
request_body_compressed = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.request_body IS NOT NULL OR $57 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.request_body_compressed, "usage".request_body_compressed)
|
||||
END ELSE "usage".request_body_compressed END,
|
||||
provider_request_headers = NULL,
|
||||
provider_request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
provider_request_body = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.provider_request_body_compressed IS NOT NULL OR $58 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.provider_request_body, "usage".provider_request_body)
|
||||
END ELSE "usage".provider_request_body END,
|
||||
provider_request_body_compressed = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
provider_request_body_compressed = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.provider_request_body IS NOT NULL OR $58 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.provider_request_body_compressed, "usage".provider_request_body_compressed)
|
||||
END ELSE "usage".provider_request_body_compressed END,
|
||||
response_headers = NULL,
|
||||
response_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
response_body = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.response_body_compressed IS NOT NULL OR $59 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.response_body, "usage".response_body)
|
||||
END ELSE "usage".response_body END,
|
||||
response_body_compressed = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
response_body_compressed = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.response_body IS NOT NULL OR $59 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.response_body_compressed, "usage".response_body_compressed)
|
||||
END ELSE "usage".response_body_compressed END,
|
||||
client_response_headers = NULL,
|
||||
client_response_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
client_response_body = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.client_response_body_compressed IS NOT NULL OR $60 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.client_response_body, "usage".client_response_body)
|
||||
END ELSE "usage".client_response_body END,
|
||||
client_response_body_compressed = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||
client_response_body_compressed = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN CASE
|
||||
WHEN EXCLUDED.client_response_body IS NOT NULL OR $60 THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.client_response_body_compressed, "usage".client_response_body_compressed)
|
||||
END ELSE "usage".client_response_body_compressed END,
|
||||
request_metadata = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.request_metadata, "usage".request_metadata) ELSE "usage".request_metadata END,
|
||||
request_metadata = CASE WHEN "usage".billing_status = 'pending' AND $61 THEN COALESCE(EXCLUDED.request_metadata, "usage".request_metadata) ELSE "usage".request_metadata END,
|
||||
finalized_at = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.finalized_at, "usage".finalized_at) ELSE "usage".finalized_at END,
|
||||
updated_at_unix_secs = CASE WHEN "usage".billing_status = 'pending' THEN
|
||||
GREATEST(
|
||||
|
||||
+34
-15
@@ -41,16 +41,25 @@ DO UPDATE SET
|
||||
EXCLUDED.client_response_headers,
|
||||
usage_http_audits.client_response_headers
|
||||
),
|
||||
request_body_ref = COALESCE(EXCLUDED.request_body_ref, usage_http_audits.request_body_ref),
|
||||
provider_request_body_ref = COALESCE(
|
||||
EXCLUDED.provider_request_body_ref,
|
||||
usage_http_audits.provider_request_body_ref
|
||||
),
|
||||
response_body_ref = COALESCE(EXCLUDED.response_body_ref, usage_http_audits.response_body_ref),
|
||||
client_response_body_ref = COALESCE(
|
||||
EXCLUDED.client_response_body_ref,
|
||||
usage_http_audits.client_response_body_ref
|
||||
),
|
||||
request_body_ref = CASE
|
||||
WHEN EXCLUDED.request_body_state = 'none' THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.request_body_ref, usage_http_audits.request_body_ref)
|
||||
END,
|
||||
provider_request_body_ref = CASE
|
||||
WHEN EXCLUDED.provider_request_body_state = 'none' THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.provider_request_body_ref, usage_http_audits.provider_request_body_ref)
|
||||
END,
|
||||
response_body_ref = CASE
|
||||
WHEN EXCLUDED.response_body_state = 'none' THEN NULL
|
||||
ELSE COALESCE(EXCLUDED.response_body_ref, usage_http_audits.response_body_ref)
|
||||
END,
|
||||
client_response_body_ref = CASE
|
||||
WHEN EXCLUDED.client_response_body_state = 'none' THEN NULL
|
||||
ELSE COALESCE(
|
||||
EXCLUDED.client_response_body_ref,
|
||||
usage_http_audits.client_response_body_ref
|
||||
)
|
||||
END,
|
||||
request_body_state = COALESCE(
|
||||
EXCLUDED.request_body_state,
|
||||
usage_http_audits.request_body_state
|
||||
@@ -67,9 +76,19 @@ DO UPDATE SET
|
||||
EXCLUDED.client_response_body_state,
|
||||
usage_http_audits.client_response_body_state
|
||||
),
|
||||
body_capture_mode = COALESCE(
|
||||
NULLIF(EXCLUDED.body_capture_mode, 'none'),
|
||||
usage_http_audits.body_capture_mode,
|
||||
'none'
|
||||
),
|
||||
body_capture_mode = CASE
|
||||
WHEN EXCLUDED.body_capture_mode = 'none'
|
||||
AND (
|
||||
EXCLUDED.request_body_state = 'none'
|
||||
OR EXCLUDED.provider_request_body_state = 'none'
|
||||
OR EXCLUDED.response_body_state = 'none'
|
||||
OR EXCLUDED.client_response_body_state = 'none'
|
||||
)
|
||||
THEN 'none'
|
||||
ELSE COALESCE(
|
||||
NULLIF(EXCLUDED.body_capture_mode, 'none'),
|
||||
usage_http_audits.body_capture_mode,
|
||||
'none'
|
||||
)
|
||||
END,
|
||||
updated_at = NOW()
|
||||
|
||||
+12
-30
@@ -29,34 +29,16 @@ INSERT INTO usage_routing_snapshots (
|
||||
)
|
||||
ON CONFLICT (request_id)
|
||||
DO UPDATE SET
|
||||
candidate_id = COALESCE(EXCLUDED.candidate_id, usage_routing_snapshots.candidate_id),
|
||||
candidate_index = COALESCE(
|
||||
EXCLUDED.candidate_index,
|
||||
usage_routing_snapshots.candidate_index
|
||||
),
|
||||
key_name = COALESCE(EXCLUDED.key_name, usage_routing_snapshots.key_name),
|
||||
planner_kind = COALESCE(EXCLUDED.planner_kind, usage_routing_snapshots.planner_kind),
|
||||
route_family = COALESCE(EXCLUDED.route_family, usage_routing_snapshots.route_family),
|
||||
route_kind = COALESCE(EXCLUDED.route_kind, usage_routing_snapshots.route_kind),
|
||||
execution_path = COALESCE(EXCLUDED.execution_path, usage_routing_snapshots.execution_path),
|
||||
local_execution_runtime_miss_reason = COALESCE(
|
||||
EXCLUDED.local_execution_runtime_miss_reason,
|
||||
usage_routing_snapshots.local_execution_runtime_miss_reason
|
||||
),
|
||||
selected_provider_id = COALESCE(
|
||||
EXCLUDED.selected_provider_id,
|
||||
usage_routing_snapshots.selected_provider_id
|
||||
),
|
||||
selected_endpoint_id = COALESCE(
|
||||
EXCLUDED.selected_endpoint_id,
|
||||
usage_routing_snapshots.selected_endpoint_id
|
||||
),
|
||||
selected_provider_api_key_id = COALESCE(
|
||||
EXCLUDED.selected_provider_api_key_id,
|
||||
usage_routing_snapshots.selected_provider_api_key_id
|
||||
),
|
||||
has_format_conversion = COALESCE(
|
||||
EXCLUDED.has_format_conversion,
|
||||
usage_routing_snapshots.has_format_conversion
|
||||
),
|
||||
candidate_id = CASE WHEN $14 THEN EXCLUDED.candidate_id ELSE COALESCE(EXCLUDED.candidate_id, usage_routing_snapshots.candidate_id) END,
|
||||
candidate_index = CASE WHEN $14 THEN EXCLUDED.candidate_index ELSE COALESCE(EXCLUDED.candidate_index, usage_routing_snapshots.candidate_index) END,
|
||||
key_name = CASE WHEN $14 THEN EXCLUDED.key_name ELSE COALESCE(EXCLUDED.key_name, usage_routing_snapshots.key_name) END,
|
||||
planner_kind = CASE WHEN $14 THEN EXCLUDED.planner_kind ELSE COALESCE(EXCLUDED.planner_kind, usage_routing_snapshots.planner_kind) END,
|
||||
route_family = CASE WHEN $14 THEN EXCLUDED.route_family ELSE COALESCE(EXCLUDED.route_family, usage_routing_snapshots.route_family) END,
|
||||
route_kind = CASE WHEN $14 THEN EXCLUDED.route_kind ELSE COALESCE(EXCLUDED.route_kind, usage_routing_snapshots.route_kind) END,
|
||||
execution_path = CASE WHEN $14 THEN EXCLUDED.execution_path ELSE COALESCE(EXCLUDED.execution_path, usage_routing_snapshots.execution_path) END,
|
||||
local_execution_runtime_miss_reason = CASE WHEN $14 THEN EXCLUDED.local_execution_runtime_miss_reason ELSE COALESCE(EXCLUDED.local_execution_runtime_miss_reason, usage_routing_snapshots.local_execution_runtime_miss_reason) END,
|
||||
selected_provider_id = CASE WHEN $14 THEN EXCLUDED.selected_provider_id ELSE COALESCE(EXCLUDED.selected_provider_id, usage_routing_snapshots.selected_provider_id) END,
|
||||
selected_endpoint_id = CASE WHEN $14 THEN EXCLUDED.selected_endpoint_id ELSE COALESCE(EXCLUDED.selected_endpoint_id, usage_routing_snapshots.selected_endpoint_id) END,
|
||||
selected_provider_api_key_id = CASE WHEN $14 THEN EXCLUDED.selected_provider_api_key_id ELSE COALESCE(EXCLUDED.selected_provider_api_key_id, usage_routing_snapshots.selected_provider_api_key_id) END,
|
||||
has_format_conversion = CASE WHEN $14 THEN EXCLUDED.has_format_conversion ELSE COALESCE(EXCLUDED.has_format_conversion, usage_routing_snapshots.has_format_conversion) END,
|
||||
updated_at = NOW()
|
||||
|
||||
+28
-108
@@ -61,112 +61,32 @@ INSERT INTO usage_settlement_snapshots (
|
||||
)
|
||||
ON CONFLICT (request_id)
|
||||
DO UPDATE SET
|
||||
billing_snapshot_schema_version = COALESCE(
|
||||
EXCLUDED.billing_snapshot_schema_version,
|
||||
usage_settlement_snapshots.billing_snapshot_schema_version
|
||||
),
|
||||
billing_snapshot_status = COALESCE(
|
||||
EXCLUDED.billing_snapshot_status,
|
||||
usage_settlement_snapshots.billing_snapshot_status
|
||||
),
|
||||
settlement_snapshot_schema_version = COALESCE(
|
||||
EXCLUDED.settlement_snapshot_schema_version,
|
||||
usage_settlement_snapshots.settlement_snapshot_schema_version
|
||||
),
|
||||
settlement_snapshot = COALESCE(
|
||||
EXCLUDED.settlement_snapshot,
|
||||
usage_settlement_snapshots.settlement_snapshot
|
||||
),
|
||||
billing_dimensions = COALESCE(
|
||||
EXCLUDED.billing_dimensions,
|
||||
usage_settlement_snapshots.billing_dimensions
|
||||
),
|
||||
billing_input_tokens = COALESCE(
|
||||
EXCLUDED.billing_input_tokens,
|
||||
usage_settlement_snapshots.billing_input_tokens
|
||||
),
|
||||
billing_effective_input_tokens = COALESCE(
|
||||
EXCLUDED.billing_effective_input_tokens,
|
||||
usage_settlement_snapshots.billing_effective_input_tokens
|
||||
),
|
||||
billing_output_tokens = COALESCE(
|
||||
EXCLUDED.billing_output_tokens,
|
||||
usage_settlement_snapshots.billing_output_tokens
|
||||
),
|
||||
billing_cache_creation_tokens = COALESCE(
|
||||
EXCLUDED.billing_cache_creation_tokens,
|
||||
usage_settlement_snapshots.billing_cache_creation_tokens
|
||||
),
|
||||
billing_cache_creation_5m_tokens = COALESCE(
|
||||
EXCLUDED.billing_cache_creation_5m_tokens,
|
||||
usage_settlement_snapshots.billing_cache_creation_5m_tokens
|
||||
),
|
||||
billing_cache_creation_1h_tokens = COALESCE(
|
||||
EXCLUDED.billing_cache_creation_1h_tokens,
|
||||
usage_settlement_snapshots.billing_cache_creation_1h_tokens
|
||||
),
|
||||
billing_cache_read_tokens = COALESCE(
|
||||
EXCLUDED.billing_cache_read_tokens,
|
||||
usage_settlement_snapshots.billing_cache_read_tokens
|
||||
),
|
||||
billing_total_input_context = COALESCE(
|
||||
EXCLUDED.billing_total_input_context,
|
||||
usage_settlement_snapshots.billing_total_input_context
|
||||
),
|
||||
billing_cache_creation_cost_usd = COALESCE(
|
||||
EXCLUDED.billing_cache_creation_cost_usd,
|
||||
usage_settlement_snapshots.billing_cache_creation_cost_usd
|
||||
),
|
||||
billing_cache_read_cost_usd = COALESCE(
|
||||
EXCLUDED.billing_cache_read_cost_usd,
|
||||
usage_settlement_snapshots.billing_cache_read_cost_usd
|
||||
),
|
||||
billing_total_cost_usd = COALESCE(
|
||||
EXCLUDED.billing_total_cost_usd,
|
||||
usage_settlement_snapshots.billing_total_cost_usd
|
||||
),
|
||||
billing_actual_total_cost_usd = COALESCE(
|
||||
EXCLUDED.billing_actual_total_cost_usd,
|
||||
usage_settlement_snapshots.billing_actual_total_cost_usd
|
||||
),
|
||||
billing_pricing_source = COALESCE(
|
||||
EXCLUDED.billing_pricing_source,
|
||||
usage_settlement_snapshots.billing_pricing_source
|
||||
),
|
||||
billing_rule_id = COALESCE(
|
||||
EXCLUDED.billing_rule_id,
|
||||
usage_settlement_snapshots.billing_rule_id
|
||||
),
|
||||
billing_rule_version = COALESCE(
|
||||
EXCLUDED.billing_rule_version,
|
||||
usage_settlement_snapshots.billing_rule_version
|
||||
),
|
||||
rate_multiplier = COALESCE(
|
||||
EXCLUDED.rate_multiplier,
|
||||
usage_settlement_snapshots.rate_multiplier
|
||||
),
|
||||
is_free_tier = COALESCE(
|
||||
EXCLUDED.is_free_tier,
|
||||
usage_settlement_snapshots.is_free_tier
|
||||
),
|
||||
input_price_per_1m = COALESCE(
|
||||
EXCLUDED.input_price_per_1m,
|
||||
usage_settlement_snapshots.input_price_per_1m
|
||||
),
|
||||
output_price_per_1m = COALESCE(
|
||||
EXCLUDED.output_price_per_1m,
|
||||
usage_settlement_snapshots.output_price_per_1m
|
||||
),
|
||||
cache_creation_price_per_1m = COALESCE(
|
||||
EXCLUDED.cache_creation_price_per_1m,
|
||||
usage_settlement_snapshots.cache_creation_price_per_1m
|
||||
),
|
||||
cache_read_price_per_1m = COALESCE(
|
||||
EXCLUDED.cache_read_price_per_1m,
|
||||
usage_settlement_snapshots.cache_read_price_per_1m
|
||||
),
|
||||
price_per_request = COALESCE(
|
||||
EXCLUDED.price_per_request,
|
||||
usage_settlement_snapshots.price_per_request
|
||||
),
|
||||
billing_status = CASE WHEN $30 THEN EXCLUDED.billing_status ELSE usage_settlement_snapshots.billing_status END,
|
||||
billing_snapshot_schema_version = CASE WHEN $30 THEN EXCLUDED.billing_snapshot_schema_version ELSE COALESCE(EXCLUDED.billing_snapshot_schema_version, usage_settlement_snapshots.billing_snapshot_schema_version) END,
|
||||
billing_snapshot_status = CASE WHEN $30 THEN EXCLUDED.billing_snapshot_status ELSE COALESCE(EXCLUDED.billing_snapshot_status, usage_settlement_snapshots.billing_snapshot_status) END,
|
||||
settlement_snapshot_schema_version = CASE WHEN $30 THEN EXCLUDED.settlement_snapshot_schema_version ELSE COALESCE(EXCLUDED.settlement_snapshot_schema_version, usage_settlement_snapshots.settlement_snapshot_schema_version) END,
|
||||
settlement_snapshot = CASE WHEN $30 THEN EXCLUDED.settlement_snapshot ELSE COALESCE(EXCLUDED.settlement_snapshot, usage_settlement_snapshots.settlement_snapshot) END,
|
||||
billing_dimensions = CASE WHEN $30 THEN EXCLUDED.billing_dimensions ELSE COALESCE(EXCLUDED.billing_dimensions, usage_settlement_snapshots.billing_dimensions) END,
|
||||
billing_input_tokens = CASE WHEN $30 THEN EXCLUDED.billing_input_tokens ELSE COALESCE(EXCLUDED.billing_input_tokens, usage_settlement_snapshots.billing_input_tokens) END,
|
||||
billing_effective_input_tokens = CASE WHEN $30 THEN EXCLUDED.billing_effective_input_tokens ELSE COALESCE(EXCLUDED.billing_effective_input_tokens, usage_settlement_snapshots.billing_effective_input_tokens) END,
|
||||
billing_output_tokens = CASE WHEN $30 THEN EXCLUDED.billing_output_tokens ELSE COALESCE(EXCLUDED.billing_output_tokens, usage_settlement_snapshots.billing_output_tokens) END,
|
||||
billing_cache_creation_tokens = CASE WHEN $30 THEN EXCLUDED.billing_cache_creation_tokens ELSE COALESCE(EXCLUDED.billing_cache_creation_tokens, usage_settlement_snapshots.billing_cache_creation_tokens) END,
|
||||
billing_cache_creation_5m_tokens = CASE WHEN $30 THEN EXCLUDED.billing_cache_creation_5m_tokens ELSE COALESCE(EXCLUDED.billing_cache_creation_5m_tokens, usage_settlement_snapshots.billing_cache_creation_5m_tokens) END,
|
||||
billing_cache_creation_1h_tokens = CASE WHEN $30 THEN EXCLUDED.billing_cache_creation_1h_tokens ELSE COALESCE(EXCLUDED.billing_cache_creation_1h_tokens, usage_settlement_snapshots.billing_cache_creation_1h_tokens) END,
|
||||
billing_cache_read_tokens = CASE WHEN $30 THEN EXCLUDED.billing_cache_read_tokens ELSE COALESCE(EXCLUDED.billing_cache_read_tokens, usage_settlement_snapshots.billing_cache_read_tokens) END,
|
||||
billing_total_input_context = CASE WHEN $30 THEN EXCLUDED.billing_total_input_context ELSE COALESCE(EXCLUDED.billing_total_input_context, usage_settlement_snapshots.billing_total_input_context) END,
|
||||
billing_cache_creation_cost_usd = CASE WHEN $30 THEN EXCLUDED.billing_cache_creation_cost_usd ELSE COALESCE(EXCLUDED.billing_cache_creation_cost_usd, usage_settlement_snapshots.billing_cache_creation_cost_usd) END,
|
||||
billing_cache_read_cost_usd = CASE WHEN $30 THEN EXCLUDED.billing_cache_read_cost_usd ELSE COALESCE(EXCLUDED.billing_cache_read_cost_usd, usage_settlement_snapshots.billing_cache_read_cost_usd) END,
|
||||
billing_total_cost_usd = CASE WHEN $30 THEN EXCLUDED.billing_total_cost_usd ELSE COALESCE(EXCLUDED.billing_total_cost_usd, usage_settlement_snapshots.billing_total_cost_usd) END,
|
||||
billing_actual_total_cost_usd = CASE WHEN $30 THEN EXCLUDED.billing_actual_total_cost_usd ELSE COALESCE(EXCLUDED.billing_actual_total_cost_usd, usage_settlement_snapshots.billing_actual_total_cost_usd) END,
|
||||
billing_pricing_source = CASE WHEN $30 THEN EXCLUDED.billing_pricing_source ELSE COALESCE(EXCLUDED.billing_pricing_source, usage_settlement_snapshots.billing_pricing_source) END,
|
||||
billing_rule_id = CASE WHEN $30 THEN EXCLUDED.billing_rule_id ELSE COALESCE(EXCLUDED.billing_rule_id, usage_settlement_snapshots.billing_rule_id) END,
|
||||
billing_rule_version = CASE WHEN $30 THEN EXCLUDED.billing_rule_version ELSE COALESCE(EXCLUDED.billing_rule_version, usage_settlement_snapshots.billing_rule_version) END,
|
||||
rate_multiplier = CASE WHEN $30 THEN EXCLUDED.rate_multiplier ELSE COALESCE(EXCLUDED.rate_multiplier, usage_settlement_snapshots.rate_multiplier) END,
|
||||
is_free_tier = CASE WHEN $30 THEN EXCLUDED.is_free_tier ELSE COALESCE(EXCLUDED.is_free_tier, usage_settlement_snapshots.is_free_tier) END,
|
||||
input_price_per_1m = CASE WHEN $30 THEN EXCLUDED.input_price_per_1m ELSE COALESCE(EXCLUDED.input_price_per_1m, usage_settlement_snapshots.input_price_per_1m) END,
|
||||
output_price_per_1m = CASE WHEN $30 THEN EXCLUDED.output_price_per_1m ELSE COALESCE(EXCLUDED.output_price_per_1m, usage_settlement_snapshots.output_price_per_1m) END,
|
||||
cache_creation_price_per_1m = CASE WHEN $30 THEN EXCLUDED.cache_creation_price_per_1m ELSE COALESCE(EXCLUDED.cache_creation_price_per_1m, usage_settlement_snapshots.cache_creation_price_per_1m) END,
|
||||
cache_read_price_per_1m = CASE WHEN $30 THEN EXCLUDED.cache_read_price_per_1m ELSE COALESCE(EXCLUDED.cache_read_price_per_1m, usage_settlement_snapshots.cache_read_price_per_1m) END,
|
||||
price_per_request = CASE WHEN $30 THEN EXCLUDED.price_per_request ELSE COALESCE(EXCLUDED.price_per_request, usage_settlement_snapshots.price_per_request) END,
|
||||
updated_at = NOW()
|
||||
|
||||
@@ -1,25 +1,497 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{
|
||||
attach_compressed_body_refs, attach_usage_http_audit_body_refs,
|
||||
attach_usage_routing_snapshot_metadata, attach_usage_settlement_pricing_snapshot_metadata,
|
||||
inflate_usage_json_value, prepare_request_metadata_for_body_storage,
|
||||
prepare_usage_body_storage, resolved_read_usage_body_ref, resolved_write_usage_body_ref,
|
||||
clear_previous_request_body_facts, inflate_usage_json_value,
|
||||
prepare_request_metadata_for_body_storage, prepare_usage_body_storage,
|
||||
prepare_usage_upsert_context, request_body_capture_replaces_derived_facts,
|
||||
resolved_read_usage_body_ref, resolved_write_usage_body_ref,
|
||||
split_dashboard_daily_aggregate_range, split_dashboard_hourly_aggregate_range,
|
||||
usage_body_capture_state_for_storage, usage_body_ref, usage_effective_input_tokens,
|
||||
usage_http_audit_body_refs, usage_http_audit_capture_mode, usage_routing_snapshot_from_usage,
|
||||
usage_settlement_pricing_snapshot_from_usage, usage_total_input_context, AggregateRangeSplit,
|
||||
SqlxUsageReadRepository, UsageHttpAuditRefs, UsageRoutingSnapshot,
|
||||
UsageSettlementPricingSnapshot, MAX_INLINE_USAGE_BODY_BYTES,
|
||||
usage_body_capture_state_for_storage, usage_body_ref, usage_capture_update_allowed,
|
||||
usage_effective_input_tokens, usage_http_audit_body_refs, usage_http_audit_capture_mode,
|
||||
usage_routing_snapshot_from_usage, usage_settlement_pricing_snapshot_from_usage,
|
||||
usage_total_input_context, AggregateRangeSplit, SqlxUsageReadRepository, UsageHttpAuditRefs,
|
||||
UsageRoutingSnapshot, UsageSettlementPricingSnapshot, MAX_INLINE_USAGE_BODY_BYTES,
|
||||
};
|
||||
use crate::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
use aether_data_contracts::repository::usage::{
|
||||
UpsertUsageRecord, UsageBodyCaptureState, UsageBodyField, UsageCostSavingsSummaryQuery,
|
||||
UsageDashboardDailyBreakdownQuery, UsageDashboardSummaryQuery, UsageProviderPerformanceQuery,
|
||||
UsageTimeSeriesGranularity,
|
||||
UpsertUsageRecord, UsageAuditListQuery, UsageBodyCaptureState, UsageBodyField,
|
||||
UsageCostSavingsSummaryQuery, UsageDashboardDailyBreakdownQuery, UsageDashboardSummaryQuery,
|
||||
UsageProviderPerformanceQuery, UsageTimeSeriesGranularity,
|
||||
};
|
||||
|
||||
fn fast_clear_usage_record(
|
||||
request_id: &str,
|
||||
provider_name: &str,
|
||||
now_unix_secs: u64,
|
||||
terminal: bool,
|
||||
terminal_state: UsageBodyCaptureState,
|
||||
terminal_service_tier: Option<&str>,
|
||||
) -> UpsertUsageRecord {
|
||||
UpsertUsageRecord {
|
||||
request_id: request_id.to_string(),
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
provider_name: provider_name.to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
target_model: Some("gpt-5".to_string()),
|
||||
provider_id: None,
|
||||
provider_endpoint_id: None,
|
||||
provider_api_key_id: None,
|
||||
request_type: Some("chat".to_string()),
|
||||
api_format: Some("openai:chat".to_string()),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
endpoint_api_format: Some("openai:chat".to_string()),
|
||||
provider_api_family: Some("openai".to_string()),
|
||||
provider_endpoint_kind: Some("chat".to_string()),
|
||||
has_format_conversion: Some(false),
|
||||
is_stream: Some(false),
|
||||
input_tokens: terminal.then_some(1),
|
||||
output_tokens: terminal.then_some(1),
|
||||
total_tokens: terminal.then_some(2),
|
||||
cache_creation_input_tokens: None,
|
||||
cache_creation_ephemeral_5m_input_tokens: None,
|
||||
cache_creation_ephemeral_1h_input_tokens: None,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_cost_usd: None,
|
||||
cache_read_cost_usd: None,
|
||||
output_price_per_1m: None,
|
||||
total_cost_usd: None,
|
||||
actual_total_cost_usd: None,
|
||||
status_code: terminal.then_some(200),
|
||||
error_message: None,
|
||||
error_category: None,
|
||||
response_time_ms: terminal.then_some(10),
|
||||
first_byte_time_ms: None,
|
||||
status: if terminal { "completed" } else { "pending" }.to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
request_body_ref: None,
|
||||
request_body_state: None,
|
||||
provider_request_headers: None,
|
||||
provider_request_body: (!terminal).then(|| {
|
||||
json!({
|
||||
"model": "gpt-5",
|
||||
"service_tier": "priority"
|
||||
})
|
||||
}),
|
||||
provider_request_body_ref: None,
|
||||
provider_request_body_state: Some(if terminal {
|
||||
terminal_state
|
||||
} else {
|
||||
UsageBodyCaptureState::Inline
|
||||
}),
|
||||
response_headers: None,
|
||||
response_body: None,
|
||||
response_body_ref: None,
|
||||
response_body_state: None,
|
||||
client_response_headers: None,
|
||||
client_response_body: None,
|
||||
client_response_body_ref: None,
|
||||
client_response_body_state: None,
|
||||
candidate_id: None,
|
||||
candidate_index: None,
|
||||
key_name: None,
|
||||
planner_kind: None,
|
||||
route_family: None,
|
||||
route_kind: None,
|
||||
execution_path: None,
|
||||
local_execution_runtime_miss_reason: None,
|
||||
request_metadata: if terminal {
|
||||
terminal_service_tier.map(|tier| json!({"provider_service_tier": tier}))
|
||||
} else {
|
||||
Some(json!({"provider_service_tier": "priority"}))
|
||||
},
|
||||
finalized_at_unix_secs: terminal.then_some(now_unix_secs + 1),
|
||||
created_at_unix_ms: Some(now_unix_secs),
|
||||
updated_at_unix_secs: now_unix_secs + u64::from(terminal),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL migrations"]
|
||||
async fn live_terminal_none_capture_clears_fast_from_detail_and_lightweight_lists() {
|
||||
let database_url = std::env::var("AETHER_TEST_DATABASE_URL")
|
||||
.expect("AETHER_TEST_DATABASE_URL must point at the test database");
|
||||
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
||||
database_url,
|
||||
min_connections: 1,
|
||||
max_connections: 2,
|
||||
acquire_timeout_ms: 10_000,
|
||||
idle_timeout_ms: 30_000,
|
||||
max_lifetime_ms: 60_000,
|
||||
statement_cache_capacity: 64,
|
||||
require_ssl: false,
|
||||
})
|
||||
.expect("factory should build");
|
||||
let repository =
|
||||
SqlxUsageReadRepository::new(factory.connect_lazy().expect("lazy pool should build"));
|
||||
crate::run_migrations(repository.pool())
|
||||
.await
|
||||
.expect("test database migrations should succeed");
|
||||
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let request_id = format!("req-fast-clear-{suffix}");
|
||||
let provider_name = format!("fast-clear-{suffix}");
|
||||
let now_unix_secs = Utc::now().timestamp().max(0) as u64;
|
||||
|
||||
let mut pending_record = fast_clear_usage_record(
|
||||
&request_id,
|
||||
&provider_name,
|
||||
now_unix_secs,
|
||||
false,
|
||||
UsageBodyCaptureState::Inline,
|
||||
Some("priority"),
|
||||
);
|
||||
pending_record.candidate_id = Some("candidate-a".to_string());
|
||||
pending_record.candidate_index = Some(1);
|
||||
pending_record.key_name = Some("key-a".to_string());
|
||||
pending_record.planner_kind = Some("planner-a".to_string());
|
||||
pending_record.route_family = Some("route-family-a".to_string());
|
||||
pending_record.route_kind = Some("route-kind-a".to_string());
|
||||
pending_record.execution_path = Some("path-a".to_string());
|
||||
pending_record.local_execution_runtime_miss_reason = Some("miss-a".to_string());
|
||||
pending_record.request_metadata = Some(json!({
|
||||
"trace_id": "trace-a",
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "priority",
|
||||
"billing_snapshot": {
|
||||
"schema_version": "2.0",
|
||||
"status": "complete",
|
||||
"resolved_variables": {
|
||||
"input_price_per_1m": 30.0,
|
||||
"output_price_per_1m": 150.0
|
||||
}
|
||||
},
|
||||
"settlement_snapshot": {
|
||||
"schema_version": "2.0",
|
||||
"pricing_snapshot": {
|
||||
"pricing_source": "processing_tier",
|
||||
"service_tier": "priority"
|
||||
},
|
||||
"billing_plan_snapshot": {
|
||||
"rule_id": "fast-rule",
|
||||
"rule_version": "1"
|
||||
}
|
||||
},
|
||||
"billing_dimensions": {"service_tier": "priority"},
|
||||
"rate_multiplier": 2.0,
|
||||
"input_price_per_1m": 30.0,
|
||||
"output_price_per_1m": 150.0
|
||||
}));
|
||||
let pending = repository
|
||||
.upsert(pending_record)
|
||||
.await
|
||||
.expect("pending usage should persist");
|
||||
assert_eq!(pending.provider_service_tier().as_deref(), Some("priority"));
|
||||
|
||||
let mut terminal_record = fast_clear_usage_record(
|
||||
&request_id,
|
||||
&provider_name,
|
||||
now_unix_secs,
|
||||
true,
|
||||
UsageBodyCaptureState::None,
|
||||
None,
|
||||
);
|
||||
terminal_record.provider_id = Some("final-provider-id".to_string());
|
||||
terminal_record.provider_endpoint_id = Some("final-endpoint-id".to_string());
|
||||
terminal_record.provider_api_key_id = Some("final-key-id".to_string());
|
||||
terminal_record.target_model = None;
|
||||
let terminal = repository
|
||||
.upsert(terminal_record)
|
||||
.await
|
||||
.expect("terminal usage should persist");
|
||||
assert_eq!(terminal.provider_service_tier(), None);
|
||||
|
||||
let stored = repository
|
||||
.find_by_request_id(&request_id)
|
||||
.await
|
||||
.expect("detail lookup should succeed")
|
||||
.expect("usage should exist");
|
||||
assert_eq!(
|
||||
stored.provider_request_body_state,
|
||||
Some(UsageBodyCaptureState::None)
|
||||
);
|
||||
assert_eq!(stored.provider_service_tier(), None);
|
||||
assert_eq!(stored.provider_actual_service_tier(), None);
|
||||
let stored_metadata = stored
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("terminal audit metadata should remain");
|
||||
assert_eq!(
|
||||
stored_metadata
|
||||
.get("trace_id")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("trace-a")
|
||||
);
|
||||
for stale_key in [
|
||||
"provider_actual_service_tier",
|
||||
"billing_snapshot",
|
||||
"settlement_snapshot",
|
||||
"billing_dimensions",
|
||||
"rate_multiplier",
|
||||
"input_price_per_1m",
|
||||
"output_price_per_1m",
|
||||
"candidate_id",
|
||||
] {
|
||||
assert!(
|
||||
stored_metadata.get(stale_key).is_none(),
|
||||
"terminal metadata retained stale key {stale_key}"
|
||||
);
|
||||
}
|
||||
assert_eq!(stored.settlement_rate_multiplier(), None);
|
||||
assert_eq!(stored.settlement_input_price_per_1m(), None);
|
||||
assert_eq!(stored.settlement_output_price_per_1m(), None);
|
||||
|
||||
let settlement_row = sqlx::query(
|
||||
"SELECT settlement_snapshot, billing_dimensions, CAST(rate_multiplier AS DOUBLE PRECISION) AS rate_multiplier, CAST(input_price_per_1m AS DOUBLE PRECISION) AS input_price_per_1m, CAST(output_price_per_1m AS DOUBLE PRECISION) AS output_price_per_1m FROM usage_settlement_snapshots WHERE request_id = $1",
|
||||
)
|
||||
.bind(&request_id)
|
||||
.fetch_one(repository.pool())
|
||||
.await
|
||||
.expect("settlement snapshot row should be readable");
|
||||
assert!(settlement_row
|
||||
.try_get::<Option<serde_json::Value>, _>("settlement_snapshot")
|
||||
.expect("settlement snapshot should decode")
|
||||
.is_none());
|
||||
assert!(settlement_row
|
||||
.try_get::<Option<serde_json::Value>, _>("billing_dimensions")
|
||||
.expect("billing dimensions should decode")
|
||||
.is_none());
|
||||
assert!(settlement_row
|
||||
.try_get::<Option<f64>, _>("rate_multiplier")
|
||||
.expect("rate multiplier should decode")
|
||||
.is_none());
|
||||
assert!(settlement_row
|
||||
.try_get::<Option<f64>, _>("input_price_per_1m")
|
||||
.expect("input price should decode")
|
||||
.is_none());
|
||||
assert!(settlement_row
|
||||
.try_get::<Option<f64>, _>("output_price_per_1m")
|
||||
.expect("output price should decode")
|
||||
.is_none());
|
||||
|
||||
let physical_metadata = sqlx::query_scalar::<_, Option<serde_json::Value>>(
|
||||
"SELECT request_metadata FROM \"usage\" WHERE request_id = $1",
|
||||
)
|
||||
.bind(&request_id)
|
||||
.fetch_one(repository.pool())
|
||||
.await
|
||||
.expect("physical metadata should be readable")
|
||||
.expect("clear tombstone should be stored");
|
||||
assert!(physical_metadata.get("provider_service_tier").is_none());
|
||||
|
||||
let physical_body = sqlx::query(
|
||||
"SELECT provider_request_body, provider_request_body_compressed FROM \"usage\" WHERE request_id = $1",
|
||||
)
|
||||
.bind(&request_id)
|
||||
.fetch_one(repository.pool())
|
||||
.await
|
||||
.expect("physical body columns should be readable");
|
||||
assert!(physical_body
|
||||
.try_get::<Option<serde_json::Value>, _>("provider_request_body")
|
||||
.expect("provider body column should decode")
|
||||
.is_none());
|
||||
assert!(physical_body
|
||||
.try_get::<Option<Vec<u8>>, _>("provider_request_body_compressed")
|
||||
.expect("compressed provider body column should decode")
|
||||
.is_none());
|
||||
|
||||
let physical_http = sqlx::query(
|
||||
"SELECT provider_request_body_ref, provider_request_body_state FROM usage_http_audits WHERE request_id = $1",
|
||||
)
|
||||
.bind(&request_id)
|
||||
.fetch_one(repository.pool())
|
||||
.await
|
||||
.expect("HTTP audit row should be readable");
|
||||
assert!(physical_http
|
||||
.try_get::<Option<String>, _>("provider_request_body_ref")
|
||||
.expect("provider body ref should decode")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
physical_http
|
||||
.try_get::<Option<String>, _>("provider_request_body_state")
|
||||
.expect("provider body state should decode")
|
||||
.as_deref(),
|
||||
Some("none")
|
||||
);
|
||||
let physical_blob_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM usage_body_blobs WHERE request_id = $1 AND body_field = 'provider_request_body'",
|
||||
)
|
||||
.bind(&request_id)
|
||||
.fetch_one(repository.pool())
|
||||
.await
|
||||
.expect("provider body blob count should be readable");
|
||||
assert_eq!(physical_blob_count, 0);
|
||||
|
||||
// A late pending candidate must not resurrect the terminal capture, metadata, or detached
|
||||
// blob after the final state has been written.
|
||||
let mut late_record = fast_clear_usage_record(
|
||||
&request_id,
|
||||
"late-provider",
|
||||
now_unix_secs + 2,
|
||||
false,
|
||||
UsageBodyCaptureState::Inline,
|
||||
Some("priority"),
|
||||
);
|
||||
late_record.model = "late-model".to_string();
|
||||
late_record.target_model = Some("late-target".to_string());
|
||||
late_record.provider_id = Some("late-provider-id".to_string());
|
||||
late_record.provider_endpoint_id = Some("late-endpoint-id".to_string());
|
||||
late_record.provider_api_key_id = Some("late-key-id".to_string());
|
||||
late_record.endpoint_api_format = Some("late:format".to_string());
|
||||
late_record.candidate_id = Some("late-candidate".to_string());
|
||||
let late = repository
|
||||
.upsert(late_record)
|
||||
.await
|
||||
.expect("late pending usage should be accepted without regressing capture");
|
||||
assert_eq!(late.provider_service_tier(), None);
|
||||
assert_eq!(late.provider_name, provider_name);
|
||||
assert_eq!(late.model, "gpt-5");
|
||||
assert_eq!(late.target_model, None);
|
||||
assert_eq!(late.provider_id.as_deref(), Some("final-provider-id"));
|
||||
assert_eq!(
|
||||
late.provider_endpoint_id.as_deref(),
|
||||
Some("final-endpoint-id")
|
||||
);
|
||||
assert_eq!(late.provider_api_key_id.as_deref(), Some("final-key-id"));
|
||||
assert_eq!(late.endpoint_api_format.as_deref(), Some("openai:chat"));
|
||||
assert_eq!(late.candidate_id, None);
|
||||
let late_blob_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM usage_body_blobs WHERE request_id = $1 AND body_field = 'provider_request_body'",
|
||||
)
|
||||
.bind(&request_id)
|
||||
.fetch_one(repository.pool())
|
||||
.await
|
||||
.expect("late provider body blob count should be readable");
|
||||
assert_eq!(late_blob_count, 0);
|
||||
|
||||
let list_item = repository
|
||||
.list_usage_audits(&UsageAuditListQuery {
|
||||
provider_name: Some(provider_name.clone()),
|
||||
limit: Some(10),
|
||||
newest_first: true,
|
||||
..UsageAuditListQuery::default()
|
||||
})
|
||||
.await
|
||||
.expect("usage list should succeed")
|
||||
.into_iter()
|
||||
.find(|item| item.request_id == request_id)
|
||||
.expect("usage list should contain the test row");
|
||||
assert_eq!(list_item.provider_request_body_state, None);
|
||||
assert_eq!(list_item.provider_service_tier(), None);
|
||||
assert_eq!(list_item.target_model, None);
|
||||
assert_eq!(list_item.candidate_id, None);
|
||||
assert_eq!(list_item.candidate_index, None);
|
||||
assert_eq!(list_item.key_name, None);
|
||||
assert_eq!(list_item.planner_kind, None);
|
||||
assert_eq!(list_item.route_family, None);
|
||||
assert_eq!(list_item.route_kind, None);
|
||||
assert_eq!(list_item.execution_path, None);
|
||||
assert_eq!(list_item.local_execution_runtime_miss_reason, None);
|
||||
|
||||
let recent_item = repository
|
||||
.list_recent_usage_audits(None, 20)
|
||||
.await
|
||||
.expect("recent usage list should succeed")
|
||||
.into_iter()
|
||||
.find(|item| item.request_id == request_id)
|
||||
.expect("recent usage list should contain the test row");
|
||||
assert_eq!(recent_item.provider_request_body_state, None);
|
||||
assert_eq!(recent_item.provider_service_tier(), None);
|
||||
|
||||
sqlx::query("DELETE FROM \"usage\" WHERE request_id = $1")
|
||||
.bind(&request_id)
|
||||
.execute(repository.pool())
|
||||
.await
|
||||
.expect("test usage should be removed");
|
||||
|
||||
for (index, state, incoming_tier, expected_tier) in [
|
||||
("disabled", UsageBodyCaptureState::Disabled, None, None),
|
||||
("truncated", UsageBodyCaptureState::Truncated, None, None),
|
||||
(
|
||||
"unavailable",
|
||||
UsageBodyCaptureState::Unavailable,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"none-stale-metadata",
|
||||
UsageBodyCaptureState::None,
|
||||
Some("priority"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"disabled-preserved",
|
||||
UsageBodyCaptureState::Disabled,
|
||||
Some("priority"),
|
||||
Some("priority"),
|
||||
),
|
||||
(
|
||||
"truncated-preserved",
|
||||
UsageBodyCaptureState::Truncated,
|
||||
Some("priority"),
|
||||
Some("priority"),
|
||||
),
|
||||
] {
|
||||
let request_id = format!("req-fast-clear-{suffix}-{index}");
|
||||
let provider_name = format!("fast-clear-{suffix}-{index}");
|
||||
repository
|
||||
.upsert(fast_clear_usage_record(
|
||||
&request_id,
|
||||
&provider_name,
|
||||
now_unix_secs,
|
||||
false,
|
||||
UsageBodyCaptureState::Inline,
|
||||
Some("priority"),
|
||||
))
|
||||
.await
|
||||
.expect("typed-state pending usage should persist");
|
||||
let terminal = repository
|
||||
.upsert(fast_clear_usage_record(
|
||||
&request_id,
|
||||
&provider_name,
|
||||
now_unix_secs + 1,
|
||||
true,
|
||||
state,
|
||||
incoming_tier,
|
||||
))
|
||||
.await
|
||||
.expect("typed-state terminal usage should persist");
|
||||
assert_eq!(
|
||||
terminal.provider_service_tier().as_deref(),
|
||||
expected_tier,
|
||||
"state={state:?} should use only same-source metadata"
|
||||
);
|
||||
let list_item = repository
|
||||
.list_usage_audits(&UsageAuditListQuery {
|
||||
provider_name: Some(provider_name.clone()),
|
||||
limit: Some(10),
|
||||
newest_first: true,
|
||||
..UsageAuditListQuery::default()
|
||||
})
|
||||
.await
|
||||
.expect("typed-state list should succeed")
|
||||
.into_iter()
|
||||
.find(|item| item.request_id == request_id)
|
||||
.expect("typed-state row should be listed");
|
||||
assert_eq!(list_item.provider_service_tier().as_deref(), expected_tier);
|
||||
sqlx::query("DELETE FROM \"usage\" WHERE request_id = $1")
|
||||
.bind(&request_id)
|
||||
.execute(repository.pool())
|
||||
.await
|
||||
.expect("typed-state test usage should be removed");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires AETHER_TEST_DATABASE_URL and a populated PostgreSQL database"]
|
||||
async fn live_dashboard_combined_summary_matches_separate_queries() {
|
||||
@@ -1536,6 +2008,8 @@ fn usage_sql_uses_json_null_placeholders_for_usage_payload_columns() {
|
||||
assert!(sql.contains("request_metadata->>'client_ip'"));
|
||||
assert!(sql.contains("'user_agent'"));
|
||||
assert!(sql.contains("request_metadata->>'user_agent'"));
|
||||
assert!(sql.contains("request_metadata->>'requested_reasoning_effort'"));
|
||||
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("AS client_family"));
|
||||
@@ -1656,7 +2130,7 @@ fn usage_sql_settlement_pricing_snapshot_billing_values_use_authoritative_incomi
|
||||
"billing_actual_total_cost_usd",
|
||||
] {
|
||||
let assignment = format!(
|
||||
"{field} = COALESCE(\n EXCLUDED.{field},\n usage_settlement_snapshots.{field}\n )"
|
||||
"{field} = CASE WHEN $30 THEN EXCLUDED.{field} ELSE COALESCE(EXCLUDED.{field}, usage_settlement_snapshots.{field}) END"
|
||||
);
|
||||
assert!(
|
||||
sql.contains(assignment.as_str()),
|
||||
@@ -1672,9 +2146,9 @@ fn usage_sql_settlement_pricing_snapshot_billing_values_use_authoritative_incomi
|
||||
#[test]
|
||||
fn usage_sql_upsert_recovers_missing_provider_links_after_billing_finalizes() {
|
||||
for assignment in [
|
||||
"provider_id = CASE WHEN \"usage\".billing_status = 'pending' OR (\"usage\".provider_id IS NULL AND (\"usage\".provider_endpoint_id IS NULL OR \"usage\".provider_endpoint_id = EXCLUDED.provider_endpoint_id) AND (\"usage\".provider_api_key_id IS NULL OR \"usage\".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_id, \"usage\".provider_id) ELSE \"usage\".provider_id END",
|
||||
"provider_endpoint_id = CASE WHEN \"usage\".billing_status = 'pending' OR (\"usage\".provider_endpoint_id IS NULL AND (\"usage\".provider_id IS NULL OR \"usage\".provider_id = EXCLUDED.provider_id) AND (\"usage\".provider_api_key_id IS NULL OR \"usage\".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_endpoint_id, \"usage\".provider_endpoint_id) ELSE \"usage\".provider_endpoint_id END",
|
||||
"provider_api_key_id = CASE WHEN \"usage\".billing_status = 'pending' OR (\"usage\".provider_api_key_id IS NULL AND (\"usage\".provider_id IS NULL OR \"usage\".provider_id = EXCLUDED.provider_id) AND (\"usage\".provider_endpoint_id IS NULL OR \"usage\".provider_endpoint_id = EXCLUDED.provider_endpoint_id)) THEN COALESCE(EXCLUDED.provider_api_key_id, \"usage\".provider_api_key_id) ELSE \"usage\".provider_api_key_id END",
|
||||
"provider_id = CASE WHEN (\"usage\".billing_status = 'pending' AND $61) OR (\"usage\".billing_status <> 'pending' AND \"usage\".provider_id IS NULL AND (\"usage\".provider_endpoint_id IS NULL OR \"usage\".provider_endpoint_id = EXCLUDED.provider_endpoint_id) AND (\"usage\".provider_api_key_id IS NULL OR \"usage\".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_id, \"usage\".provider_id) ELSE \"usage\".provider_id END",
|
||||
"provider_endpoint_id = CASE WHEN (\"usage\".billing_status = 'pending' AND $61) OR (\"usage\".billing_status <> 'pending' AND \"usage\".provider_endpoint_id IS NULL AND (\"usage\".provider_id IS NULL OR \"usage\".provider_id = EXCLUDED.provider_id) AND (\"usage\".provider_api_key_id IS NULL OR \"usage\".provider_api_key_id = EXCLUDED.provider_api_key_id)) THEN COALESCE(EXCLUDED.provider_endpoint_id, \"usage\".provider_endpoint_id) ELSE \"usage\".provider_endpoint_id END",
|
||||
"provider_api_key_id = CASE WHEN (\"usage\".billing_status = 'pending' AND $61) OR (\"usage\".billing_status <> 'pending' AND \"usage\".provider_api_key_id IS NULL AND (\"usage\".provider_id IS NULL OR \"usage\".provider_id = EXCLUDED.provider_id) AND (\"usage\".provider_endpoint_id IS NULL OR \"usage\".provider_endpoint_id = EXCLUDED.provider_endpoint_id)) THEN COALESCE(EXCLUDED.provider_api_key_id, \"usage\".provider_api_key_id) ELSE \"usage\".provider_api_key_id END",
|
||||
] {
|
||||
assert!(
|
||||
super::UPSERT_SQL.contains(assignment),
|
||||
@@ -1761,6 +2235,56 @@ fn usage_sql_detached_body_flags_clear_inline_and_compressed_columns() {
|
||||
.contains("WHEN EXCLUDED.client_response_body_compressed IS NOT NULL OR $60 THEN NULL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_capture_guard_covers_bodies_metadata_and_http_ref_tombstones() {
|
||||
for assignment in [
|
||||
"request_body = CASE WHEN \"usage\".billing_status = 'pending' AND $61",
|
||||
"provider_request_body = CASE WHEN \"usage\".billing_status = 'pending' AND $61",
|
||||
"response_body = CASE WHEN \"usage\".billing_status = 'pending' AND $61",
|
||||
"client_response_body = CASE WHEN \"usage\".billing_status = 'pending' AND $61",
|
||||
"request_metadata = CASE WHEN \"usage\".billing_status = 'pending' AND $61",
|
||||
] {
|
||||
assert!(
|
||||
super::UPSERT_SQL.contains(assignment),
|
||||
"missing guard: {assignment}"
|
||||
);
|
||||
}
|
||||
for assignment in [
|
||||
"WHEN EXCLUDED.request_body_state = 'none' THEN NULL",
|
||||
"WHEN EXCLUDED.provider_request_body_state = 'none' THEN NULL",
|
||||
"WHEN EXCLUDED.response_body_state = 'none' THEN NULL",
|
||||
"WHEN EXCLUDED.client_response_body_state = 'none' THEN NULL",
|
||||
] {
|
||||
assert!(
|
||||
super::UPSERT_USAGE_HTTP_AUDIT_SQL.contains(assignment),
|
||||
"missing HTTP ref tombstone: {assignment}"
|
||||
);
|
||||
}
|
||||
let source = include_str!("mod.rs");
|
||||
assert!(source.contains(".bind(capture_update_allowed)"));
|
||||
assert!(source.contains("if capture_update_allowed {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_terminal_snapshots_replace_sparse_routing_and_settlement_facts() {
|
||||
assert!(super::UPSERT_SQL.contains(
|
||||
"target_model = CASE WHEN \"usage\".billing_status = 'pending' AND $61 THEN CASE WHEN EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN EXCLUDED.target_model"
|
||||
));
|
||||
let routing = super::UPSERT_USAGE_ROUTING_SNAPSHOT_SQL;
|
||||
assert!(routing.contains("candidate_id = CASE WHEN $14 THEN EXCLUDED.candidate_id"));
|
||||
assert!(
|
||||
routing.contains("selected_provider_id = CASE WHEN $14 THEN EXCLUDED.selected_provider_id")
|
||||
);
|
||||
let settlement = super::UPSERT_USAGE_SETTLEMENT_PRICING_SNAPSHOT_SQL;
|
||||
assert!(settlement
|
||||
.contains("settlement_snapshot = CASE WHEN $30 THEN EXCLUDED.settlement_snapshot"));
|
||||
assert!(
|
||||
settlement.contains("billing_dimensions = CASE WHEN $30 THEN EXCLUDED.billing_dimensions")
|
||||
);
|
||||
assert!(settlement.contains("rate_multiplier = CASE WHEN $30 THEN EXCLUDED.rate_multiplier"));
|
||||
assert!(include_str!("mod.rs").contains(".bind(replace_existing)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_clears_stale_failure_fields_for_non_failed_status_updates() {
|
||||
assert!(super::UPSERT_SQL.contains(
|
||||
@@ -1884,6 +2408,123 @@ fn usage_body_capture_state_for_storage_preserves_unavailable_states() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_capture_replaces_stale_request_body_facts_even_with_a_residual_body() {
|
||||
let stale_body = json!({
|
||||
"reasoning_effort": "xhigh",
|
||||
"service_tier": "priority"
|
||||
});
|
||||
|
||||
assert!(request_body_capture_replaces_derived_facts(
|
||||
Some(&stale_body),
|
||||
Some(UsageBodyCaptureState::None),
|
||||
));
|
||||
assert!(request_body_capture_replaces_derived_facts(
|
||||
Some(&stale_body),
|
||||
None,
|
||||
));
|
||||
assert!(request_body_capture_replaces_derived_facts(
|
||||
Some(&stale_body),
|
||||
Some(UsageBodyCaptureState::Disabled),
|
||||
));
|
||||
assert!(request_body_capture_replaces_derived_facts(
|
||||
None,
|
||||
Some(UsageBodyCaptureState::Truncated),
|
||||
));
|
||||
assert!(!request_body_capture_replaces_derived_facts(None, None,));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_capture_drops_residual_body_ref_and_incoming_fast_metadata_before_storage() {
|
||||
let mut usage = fast_clear_usage_record(
|
||||
"req-none-residual",
|
||||
"none-residual",
|
||||
100,
|
||||
true,
|
||||
UsageBodyCaptureState::None,
|
||||
Some("priority"),
|
||||
);
|
||||
usage.provider_request_body = Some(json!({
|
||||
"model": "gpt-5",
|
||||
"service_tier": "priority"
|
||||
}));
|
||||
usage.provider_request_body_ref =
|
||||
Some("usage://request/req-none-residual/provider_request_body".to_string());
|
||||
usage.request_metadata = Some(json!({
|
||||
"trace_id": "trace-1",
|
||||
"provider_service_tier": "priority",
|
||||
"provider_reasoning_effort": "high",
|
||||
"provider_cache_ttl_minutes": 30,
|
||||
"provider_request_body_ref": "usage://request/req-none-residual/provider_request_body"
|
||||
}));
|
||||
|
||||
let prepared = prepare_usage_upsert_context(&usage).expect("usage should prepare");
|
||||
assert!(prepared.clear_provider_request_body);
|
||||
assert!(!prepared.provider_request_body_storage.has_detached_blob());
|
||||
assert_eq!(prepared.http_audit_refs.provider_request_body_ref, None);
|
||||
assert_eq!(
|
||||
prepared.request_metadata_value,
|
||||
Some(json!({"trace_id": "trace-1"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_fact_clear_keeps_unrelated_metadata_and_emits_an_empty_tombstone() {
|
||||
let previous = json!({
|
||||
"trace_id": "trace-1",
|
||||
"requested_reasoning_effort": "xhigh",
|
||||
"provider_reasoning_effort": "max",
|
||||
"provider_service_tier": "priority",
|
||||
"provider_cache_ttl_minutes": 30,
|
||||
"provider_actual_service_tier": "default"
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
clear_previous_request_body_facts(Some(&previous), true, true),
|
||||
json!({
|
||||
"trace_id": "trace-1",
|
||||
"provider_actual_service_tier": "default"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
clear_previous_request_body_facts(
|
||||
Some(&json!({"provider_service_tier": "priority"})),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_capture_rejects_late_non_terminal_updates() {
|
||||
assert!(usage_capture_update_allowed(None, "pending"));
|
||||
assert!(usage_capture_update_allowed(
|
||||
Some(("pending", "pending")),
|
||||
"streaming",
|
||||
));
|
||||
assert!(usage_capture_update_allowed(
|
||||
Some(("failed", "pending")),
|
||||
"completed",
|
||||
));
|
||||
assert!(!usage_capture_update_allowed(
|
||||
Some(("completed", "pending")),
|
||||
"pending",
|
||||
));
|
||||
assert!(!usage_capture_update_allowed(
|
||||
Some(("failed", "pending")),
|
||||
"streaming",
|
||||
));
|
||||
assert!(!usage_capture_update_allowed(
|
||||
Some(("streaming", "pending")),
|
||||
"pending",
|
||||
));
|
||||
assert!(!usage_capture_update_allowed(
|
||||
Some(("completed", "settled")),
|
||||
"completed",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_request_metadata_for_body_storage_strips_body_ref_compatibility_keys() {
|
||||
let detached = prepare_usage_body_storage(Some(&json!({
|
||||
|
||||
@@ -156,22 +156,22 @@ INSERT INTO "usage" (
|
||||
ON CONFLICT (request_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
api_key_id = excluded.api_key_id,
|
||||
provider_name = excluded.provider_name,
|
||||
model = excluded.model,
|
||||
target_model = excluded.target_model,
|
||||
provider_id = excluded.provider_id,
|
||||
provider_endpoint_id = excluded.provider_endpoint_id,
|
||||
provider_api_key_id = excluded.provider_api_key_id,
|
||||
request_type = excluded.request_type,
|
||||
api_format = excluded.api_format,
|
||||
api_family = excluded.api_family,
|
||||
endpoint_kind = excluded.endpoint_kind,
|
||||
endpoint_api_format = excluded.endpoint_api_format,
|
||||
provider_api_family = excluded.provider_api_family,
|
||||
provider_endpoint_kind = excluded.provider_endpoint_kind,
|
||||
has_format_conversion = excluded.has_format_conversion,
|
||||
is_stream = excluded.is_stream,
|
||||
upstream_is_stream = excluded.upstream_is_stream,
|
||||
provider_name = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".provider_name ELSE excluded.provider_name END,
|
||||
model = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".model ELSE excluded.model END,
|
||||
target_model = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".target_model ELSE excluded.target_model END,
|
||||
provider_id = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".provider_id ELSE excluded.provider_id END,
|
||||
provider_endpoint_id = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".provider_endpoint_id ELSE excluded.provider_endpoint_id END,
|
||||
provider_api_key_id = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".provider_api_key_id ELSE excluded.provider_api_key_id END,
|
||||
request_type = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".request_type ELSE excluded.request_type END,
|
||||
api_format = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".api_format ELSE excluded.api_format END,
|
||||
api_family = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".api_family ELSE excluded.api_family END,
|
||||
endpoint_kind = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".endpoint_kind ELSE excluded.endpoint_kind END,
|
||||
endpoint_api_format = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".endpoint_api_format ELSE excluded.endpoint_api_format END,
|
||||
provider_api_family = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".provider_api_family ELSE excluded.provider_api_family END,
|
||||
provider_endpoint_kind = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".provider_endpoint_kind ELSE excluded.provider_endpoint_kind END,
|
||||
has_format_conversion = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".has_format_conversion ELSE excluded.has_format_conversion END,
|
||||
is_stream = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".is_stream ELSE excluded.is_stream END,
|
||||
upstream_is_stream = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".upstream_is_stream ELSE excluded.upstream_is_stream END,
|
||||
input_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".input_tokens
|
||||
ELSE excluded.input_tokens
|
||||
@@ -255,15 +255,15 @@ ON CONFLICT (request_id) DO UPDATE SET
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".billing_status
|
||||
ELSE excluded.billing_status
|
||||
END,
|
||||
request_metadata = excluded.request_metadata,
|
||||
candidate_id = COALESCE(excluded.candidate_id, "usage".candidate_id),
|
||||
candidate_index = COALESCE(excluded.candidate_index, "usage".candidate_index),
|
||||
key_name = COALESCE(excluded.key_name, "usage".key_name),
|
||||
planner_kind = excluded.planner_kind,
|
||||
route_family = excluded.route_family,
|
||||
route_kind = excluded.route_kind,
|
||||
execution_path = excluded.execution_path,
|
||||
local_execution_runtime_miss_reason = excluded.local_execution_runtime_miss_reason,
|
||||
request_metadata = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".request_metadata ELSE excluded.request_metadata END,
|
||||
candidate_id = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".candidate_id WHEN excluded.status IN ('completed', 'failed', 'cancelled') THEN excluded.candidate_id ELSE COALESCE(excluded.candidate_id, "usage".candidate_id) END,
|
||||
candidate_index = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".candidate_index WHEN excluded.status IN ('completed', 'failed', 'cancelled') THEN excluded.candidate_index ELSE COALESCE(excluded.candidate_index, "usage".candidate_index) END,
|
||||
key_name = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".key_name WHEN excluded.status IN ('completed', 'failed', 'cancelled') THEN excluded.key_name ELSE COALESCE(excluded.key_name, "usage".key_name) END,
|
||||
planner_kind = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".planner_kind ELSE excluded.planner_kind END,
|
||||
route_family = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".route_family ELSE excluded.route_family END,
|
||||
route_kind = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".route_kind ELSE excluded.route_kind END,
|
||||
execution_path = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".execution_path ELSE excluded.execution_path END,
|
||||
local_execution_runtime_miss_reason = CASE WHEN ("usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming')) OR ("usage".status = 'streaming' AND excluded.status = 'pending') THEN "usage".local_execution_runtime_miss_reason ELSE excluded.local_execution_runtime_miss_reason END,
|
||||
finalized_at = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".finalized_at
|
||||
ELSE excluded.finalized_at
|
||||
|
||||
@@ -6,6 +6,49 @@ use aether_data_contracts::repository::usage::{
|
||||
UsageReadRepository, UsageTimeSeriesGranularity, UsageWriteRepository,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn sqlite_usage_upsert_guards_candidate_identity_metadata_and_routing_from_late_lifecycle() {
|
||||
for field in [
|
||||
"provider_name",
|
||||
"model",
|
||||
"target_model",
|
||||
"provider_id",
|
||||
"provider_endpoint_id",
|
||||
"provider_api_key_id",
|
||||
"request_type",
|
||||
"api_format",
|
||||
"api_family",
|
||||
"endpoint_kind",
|
||||
"endpoint_api_format",
|
||||
"provider_api_family",
|
||||
"provider_endpoint_kind",
|
||||
"has_format_conversion",
|
||||
"is_stream",
|
||||
"upstream_is_stream",
|
||||
"request_metadata",
|
||||
"candidate_id",
|
||||
"candidate_index",
|
||||
"key_name",
|
||||
"planner_kind",
|
||||
"route_family",
|
||||
"route_kind",
|
||||
"execution_path",
|
||||
"local_execution_runtime_miss_reason",
|
||||
] {
|
||||
let assignment = format!("{field} = CASE WHEN (");
|
||||
assert!(
|
||||
super::UPSERT_USAGE_SQL.contains(&assignment),
|
||||
"missing lifecycle guard for {field}"
|
||||
);
|
||||
assert!(
|
||||
super::UPSERT_USAGE_SQL.contains(&format!("THEN \"usage\".{field}")),
|
||||
"late lifecycle must preserve {field}"
|
||||
);
|
||||
}
|
||||
assert!(super::UPSERT_USAGE_SQL
|
||||
.contains("OR (\"usage\".status = 'streaming' AND excluded.status = 'pending')"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_provider_performance_can_skip_timeline() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
@@ -139,7 +182,7 @@ async fn sqlite_usage_write_repository_does_not_regress_void_usage() {
|
||||
.expect("sqlite migrations should run");
|
||||
seed_stats_targets(&pool).await;
|
||||
|
||||
let repository = SqliteUsageWriteRepository::new(pool);
|
||||
let repository = SqliteUsageWriteRepository::new(pool.clone());
|
||||
repository
|
||||
.upsert(sample_usage("request-1", "failed", "void", 1_000))
|
||||
.await
|
||||
@@ -205,9 +248,11 @@ async fn sqlite_usage_write_repository_does_not_regress_terminal_usage_from_late
|
||||
.expect("sqlite migrations should run");
|
||||
seed_stats_targets(&pool).await;
|
||||
|
||||
let repository = SqliteUsageWriteRepository::new(pool);
|
||||
let repository = SqliteUsageWriteRepository::new(pool.clone());
|
||||
let mut terminal = sample_usage("request-1", "completed", "pending", 1_000);
|
||||
terminal.request_metadata = Some(serde_json::json!({"trace_id": "terminal-trace"}));
|
||||
repository
|
||||
.upsert(sample_usage("request-1", "completed", "pending", 1_000))
|
||||
.upsert(terminal)
|
||||
.await
|
||||
.expect("terminal usage should upsert");
|
||||
|
||||
@@ -222,6 +267,30 @@ async fn sqlite_usage_write_repository_does_not_regress_terminal_usage_from_late
|
||||
late_streaming.response_time_ms = Some(9_999);
|
||||
late_streaming.first_byte_time_ms = Some(9_999);
|
||||
late_streaming.finalized_at_unix_secs = None;
|
||||
late_streaming.provider_name = "Late Provider".to_string();
|
||||
late_streaming.model = "late-model".to_string();
|
||||
late_streaming.target_model = Some("late-target".to_string());
|
||||
late_streaming.request_type = Some("late-request".to_string());
|
||||
late_streaming.api_format = Some("late:api".to_string());
|
||||
late_streaming.api_family = Some("late-family".to_string());
|
||||
late_streaming.endpoint_kind = Some("late-endpoint".to_string());
|
||||
late_streaming.endpoint_api_format = Some("late:endpoint".to_string());
|
||||
late_streaming.provider_api_family = Some("late-provider-family".to_string());
|
||||
late_streaming.provider_endpoint_kind = Some("late-provider-endpoint".to_string());
|
||||
late_streaming.has_format_conversion = Some(false);
|
||||
late_streaming.is_stream = Some(true);
|
||||
late_streaming.candidate_id = Some("late-candidate".to_string());
|
||||
late_streaming.candidate_index = Some(99);
|
||||
late_streaming.key_name = Some("late-key".to_string());
|
||||
late_streaming.planner_kind = Some("late-planner".to_string());
|
||||
late_streaming.route_family = Some("late-route-family".to_string());
|
||||
late_streaming.route_kind = Some("late-route-kind".to_string());
|
||||
late_streaming.execution_path = Some("late-path".to_string());
|
||||
late_streaming.local_execution_runtime_miss_reason = Some("late-miss".to_string());
|
||||
late_streaming.request_metadata = Some(serde_json::json!({
|
||||
"provider_service_tier": "priority",
|
||||
"upstream_is_stream": true
|
||||
}));
|
||||
|
||||
let current = repository
|
||||
.upsert(late_streaming)
|
||||
@@ -238,6 +307,91 @@ async fn sqlite_usage_write_repository_does_not_regress_terminal_usage_from_late
|
||||
assert_eq!(current.first_byte_time_ms, Some(12));
|
||||
assert_eq!(current.finalized_at_unix_secs, Some(1_000));
|
||||
assert_eq!(current.updated_at_unix_secs, 1_000);
|
||||
assert_eq!(current.provider_name, "Provider One");
|
||||
assert_eq!(current.model, "model-1");
|
||||
assert_eq!(current.target_model.as_deref(), Some("target-model"));
|
||||
assert_eq!(current.request_type.as_deref(), Some("chat"));
|
||||
assert_eq!(current.api_format.as_deref(), Some("openai"));
|
||||
assert!(current.has_format_conversion);
|
||||
assert!(!current.is_stream);
|
||||
assert_eq!(current.candidate_id.as_deref(), Some("candidate-1"));
|
||||
assert_eq!(current.candidate_index, Some(1));
|
||||
assert_eq!(current.key_name.as_deref(), Some("key-one"));
|
||||
assert_eq!(current.planner_kind.as_deref(), Some("default"));
|
||||
assert_eq!(current.route_family.as_deref(), Some("chat"));
|
||||
assert_eq!(current.route_kind.as_deref(), Some("completion"));
|
||||
assert_eq!(current.execution_path.as_deref(), Some("remote"));
|
||||
assert_eq!(current.provider_service_tier(), None);
|
||||
assert_eq!(
|
||||
current
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("trace_id"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("terminal-trace")
|
||||
);
|
||||
|
||||
let listed = SqliteUsageReadRepository::new(pool)
|
||||
.list_usage_audits(&UsageAuditListQuery {
|
||||
limit: Some(10),
|
||||
newest_first: true,
|
||||
..UsageAuditListQuery::default()
|
||||
})
|
||||
.await
|
||||
.expect("usage list should load")
|
||||
.into_iter()
|
||||
.find(|item| item.request_id == "request-1")
|
||||
.expect("terminal usage should be listed");
|
||||
assert_eq!(listed.provider_name, "Provider One");
|
||||
assert_eq!(listed.model, "model-1");
|
||||
assert_eq!(listed.candidate_id.as_deref(), Some("candidate-1"));
|
||||
assert_eq!(listed.provider_service_tier(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_usage_write_repository_allows_authoritative_completed_recovery() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("sqlite pool should connect");
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("sqlite migrations should run");
|
||||
seed_stats_targets(&pool).await;
|
||||
|
||||
let repository = SqliteUsageWriteRepository::new(pool);
|
||||
repository
|
||||
.upsert(sample_usage("request-recovery", "failed", "void", 1_000))
|
||||
.await
|
||||
.expect("void failure should upsert");
|
||||
|
||||
let mut recovery = sample_usage("request-recovery", "completed", "pending", 1_001);
|
||||
recovery.provider_name = "Recovered Provider".to_string();
|
||||
recovery.model = "recovered-model".to_string();
|
||||
recovery.target_model = Some("recovered-target".to_string());
|
||||
recovery.api_format = Some("recovered:api".to_string());
|
||||
recovery.candidate_id = Some("recovered-candidate".to_string());
|
||||
recovery.request_metadata = Some(serde_json::json!({"provider_service_tier": "priority"}));
|
||||
let recovered = repository
|
||||
.upsert(recovery)
|
||||
.await
|
||||
.expect("completed recovery should upsert");
|
||||
|
||||
assert_eq!(recovered.status, "completed");
|
||||
assert_eq!(recovered.billing_status, "pending");
|
||||
assert_eq!(recovered.provider_name, "Recovered Provider");
|
||||
assert_eq!(recovered.model, "recovered-model");
|
||||
assert_eq!(recovered.target_model.as_deref(), Some("recovered-target"));
|
||||
assert_eq!(recovered.api_format.as_deref(), Some("recovered:api"));
|
||||
assert_eq!(
|
||||
recovered.candidate_id.as_deref(),
|
||||
Some("recovered-candidate")
|
||||
);
|
||||
assert_eq!(
|
||||
recovered.provider_service_tier().as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -279,6 +433,51 @@ async fn sqlite_usage_write_repository_preserves_streaming_response_start_from_l
|
||||
assert_eq!(current.first_byte_time_ms, Some(12));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_usage_write_repository_keeps_streaming_capture_from_late_pending() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("sqlite pool should connect");
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("sqlite migrations should run");
|
||||
seed_stats_targets(&pool).await;
|
||||
|
||||
let repository = SqliteUsageWriteRepository::new(pool);
|
||||
let mut streaming = sample_usage("request-streaming-capture", "streaming", "pending", 1_000);
|
||||
streaming.request_metadata = Some(serde_json::json!({"trace_id": "streaming-final"}));
|
||||
repository
|
||||
.upsert(streaming)
|
||||
.await
|
||||
.expect("streaming usage should upsert");
|
||||
|
||||
let mut late_pending = sample_usage("request-streaming-capture", "pending", "pending", 1_001);
|
||||
late_pending.provider_name = "Late Provider".to_string();
|
||||
late_pending.model = "late-model".to_string();
|
||||
late_pending.candidate_id = Some("late-candidate".to_string());
|
||||
late_pending.request_metadata = Some(serde_json::json!({"provider_service_tier": "priority"}));
|
||||
let current = repository
|
||||
.upsert(late_pending)
|
||||
.await
|
||||
.expect("late pending usage should not regress streaming capture");
|
||||
|
||||
assert_eq!(current.status, "streaming");
|
||||
assert_eq!(current.provider_name, "Provider One");
|
||||
assert_eq!(current.model, "model-1");
|
||||
assert_eq!(current.candidate_id.as_deref(), Some("candidate-1"));
|
||||
assert_eq!(current.provider_service_tier(), None);
|
||||
assert_eq!(
|
||||
current
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("trace_id"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("streaming-final")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_usage_write_repository_cleans_stale_pending_requests() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
|
||||
@@ -6,9 +6,10 @@ pub use types::{
|
||||
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,
|
||||
resolve_provider_cache_ttl_minutes, usage_body_ref, usage_request_metadata_client_family,
|
||||
ApiKeyLastUsedDelta, ManagementTokenCounterDelta, PendingUsageCleanupSummary,
|
||||
ProviderApiKeyWindowUsageRequest, ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary,
|
||||
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,
|
||||
StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
|
||||
StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary,
|
||||
StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
|
||||
@@ -34,5 +35,5 @@ pub use types::{
|
||||
UsageReadRepository, UsageRepository, UsageSettledCostSummaryQuery, UsageTimeSeriesGranularity,
|
||||
UsageTimeSeriesQuery, UsageWriteRepository, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY,
|
||||
PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY,
|
||||
PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
PROVIDER_SERVICE_TIER_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const PROVIDER_REASONING_EFFORT_METADATA_KEY: &str = "provider_reasoning_effort";
|
||||
pub const REQUESTED_REASONING_EFFORT_METADATA_KEY: &str = "requested_reasoning_effort";
|
||||
pub const PROVIDER_SERVICE_TIER_METADATA_KEY: &str = "provider_service_tier";
|
||||
pub const PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY: &str = "provider_actual_service_tier";
|
||||
pub const PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY: &str = "provider_cache_ttl_minutes";
|
||||
@@ -95,6 +96,89 @@ pub fn normalize_provider_service_tier(value: &str) -> Option<String> {
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// fallback is reserved for bodies that were stripped, externalized, or truncated after the tier
|
||||
/// had already been derived from that same request.
|
||||
pub fn resolve_provider_service_tier_from_request_capture(
|
||||
provider_request_body: Option<&Value>,
|
||||
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) {
|
||||
return extract_provider_service_tier_from_body(provider_request_body);
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
provider_request_body_state,
|
||||
Some(
|
||||
UsageBodyCaptureState::Inline
|
||||
| UsageBodyCaptureState::Reference
|
||||
| UsageBodyCaptureState::Truncated
|
||||
| UsageBodyCaptureState::Disabled
|
||||
| UsageBodyCaptureState::Unavailable
|
||||
)
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
request_metadata
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get(PROVIDER_SERVICE_TIER_METADATA_KEY))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
}
|
||||
|
||||
fn request_body_capture_is_authoritative(
|
||||
request_body: Option<&Value>,
|
||||
request_body_state: Option<UsageBodyCaptureState>,
|
||||
) -> bool {
|
||||
let Some(request_body) = request_body else {
|
||||
return false;
|
||||
};
|
||||
if matches!(
|
||||
request_body_state,
|
||||
Some(
|
||||
UsageBodyCaptureState::None
|
||||
| UsageBodyCaptureState::Truncated
|
||||
| UsageBodyCaptureState::Disabled
|
||||
| UsageBodyCaptureState::Unavailable
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!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")
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_reasoning_effort_from_request_capture(
|
||||
request_body: Option<&Value>,
|
||||
request_body_state: Option<UsageBodyCaptureState>,
|
||||
request_metadata: Option<&Value>,
|
||||
metadata_key: &str,
|
||||
) -> Option<String> {
|
||||
if request_body_capture_is_authoritative(request_body, request_body_state) {
|
||||
return extract_provider_reasoning_effort_from_body(request_body);
|
||||
}
|
||||
|
||||
// An explicit `none` marker describes the final capture attempt and must win over any stale
|
||||
// inline body/metadata left by an earlier candidate. `None` (the absence of a marker) remains
|
||||
// the legacy list-query shape, where metadata is the only available representation.
|
||||
if request_body_state == Some(UsageBodyCaptureState::None) {
|
||||
return None;
|
||||
}
|
||||
|
||||
request_metadata
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get(metadata_key))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_reasoning_effort)
|
||||
}
|
||||
|
||||
pub fn resolve_provider_cache_ttl_minutes(
|
||||
provider_api_format: Option<&str>,
|
||||
provider_model: Option<&str>,
|
||||
@@ -511,33 +595,40 @@ impl StoredRequestUsageAudit {
|
||||
}
|
||||
|
||||
pub fn provider_reasoning_effort(&self) -> Option<String> {
|
||||
if self
|
||||
.provider_request_body
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.is_some()
|
||||
{
|
||||
return extract_provider_reasoning_effort_from_body(
|
||||
self.provider_request_body.as_ref(),
|
||||
);
|
||||
}
|
||||
resolve_reasoning_effort_from_request_capture(
|
||||
self.provider_request_body.as_ref(),
|
||||
self.provider_request_body_state,
|
||||
self.request_metadata.as_ref(),
|
||||
PROVIDER_REASONING_EFFORT_METADATA_KEY,
|
||||
)
|
||||
}
|
||||
|
||||
self.request_metadata_string(PROVIDER_REASONING_EFFORT_METADATA_KEY)
|
||||
.and_then(normalize_provider_reasoning_effort)
|
||||
pub fn requested_reasoning_effort(&self) -> Option<String> {
|
||||
resolve_reasoning_effort_from_request_capture(
|
||||
self.request_body.as_ref(),
|
||||
self.request_body_state,
|
||||
self.request_metadata.as_ref(),
|
||||
REQUESTED_REASONING_EFFORT_METADATA_KEY,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn provider_service_tier(&self) -> Option<String> {
|
||||
if self
|
||||
.provider_request_body
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.is_some()
|
||||
{
|
||||
return extract_provider_service_tier_from_body(self.provider_request_body.as_ref());
|
||||
}
|
||||
|
||||
self.request_metadata_string(PROVIDER_SERVICE_TIER_METADATA_KEY)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
resolve_provider_service_tier_from_request_capture(
|
||||
self.provider_request_body.as_ref(),
|
||||
self.provider_request_body_state,
|
||||
self.request_metadata.as_ref(),
|
||||
)
|
||||
.or_else(|| {
|
||||
// Lightweight list queries intentionally omit bodies and typed capture state. Their
|
||||
// request metadata was normalized before persistence and is the only available copy
|
||||
// of the final provider-request fact.
|
||||
if self.provider_request_body.is_none() && self.provider_request_body_state.is_none() {
|
||||
self.request_metadata_string(PROVIDER_SERVICE_TIER_METADATA_KEY)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provider_actual_service_tier(&self) -> Option<String> {
|
||||
@@ -2684,6 +2775,126 @@ mod tests {
|
||||
assert_eq!(usage.provider_service_tier(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_and_provider_reasoning_efforts_remain_independent() {
|
||||
let mut usage = sample_usage();
|
||||
usage.request_body = Some(json!({
|
||||
"reasoning": { "effort": "XHigh" }
|
||||
}));
|
||||
usage.provider_request_body = Some(json!({
|
||||
"reasoning_effort": "max"
|
||||
}));
|
||||
usage.request_metadata = Some(json!({
|
||||
"requested_reasoning_effort": "low",
|
||||
"provider_reasoning_effort": "medium"
|
||||
}));
|
||||
|
||||
assert_eq!(usage.requested_reasoning_effort().as_deref(), Some("xhigh"));
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("max"));
|
||||
|
||||
usage.request_body = Some(json!({ "model": "gpt-5" }));
|
||||
assert_eq!(usage.requested_reasoning_effort(), None);
|
||||
|
||||
usage.request_body = None;
|
||||
assert_eq!(usage.requested_reasoning_effort().as_deref(), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_capture_ignores_stale_request_bodies_and_metadata() {
|
||||
let mut usage = sample_usage();
|
||||
usage.request_body = Some(json!({
|
||||
"reasoning": { "effort": "xhigh" }
|
||||
}));
|
||||
usage.request_body_state = Some(UsageBodyCaptureState::None);
|
||||
usage.provider_request_body = Some(json!({
|
||||
"reasoning_effort": "max",
|
||||
"service_tier": "priority"
|
||||
}));
|
||||
usage.provider_request_body_state = Some(UsageBodyCaptureState::None);
|
||||
usage.request_metadata = Some(json!({
|
||||
"requested_reasoning_effort": "high",
|
||||
"provider_reasoning_effort": "medium",
|
||||
"provider_service_tier": "priority"
|
||||
}));
|
||||
|
||||
assert_eq!(usage.requested_reasoning_effort(), None);
|
||||
assert_eq!(usage.provider_reasoning_effort(), None);
|
||||
assert_eq!(usage.provider_service_tier(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_mapping_uses_derived_metadata_after_request_bodies_are_truncated() {
|
||||
let truncated = json!({
|
||||
"truncated": true,
|
||||
"reason": "body_capture_limit_exceeded"
|
||||
});
|
||||
let mut usage = sample_usage();
|
||||
usage.request_body = Some(truncated.clone());
|
||||
usage.request_body_state = Some(UsageBodyCaptureState::Truncated);
|
||||
usage.provider_request_body = Some(truncated);
|
||||
usage.provider_request_body_state = Some(UsageBodyCaptureState::Truncated);
|
||||
usage.request_metadata = Some(json!({
|
||||
"requested_reasoning_effort": "xhigh",
|
||||
"provider_reasoning_effort": "max"
|
||||
}));
|
||||
|
||||
assert_eq!(usage.requested_reasoning_effort().as_deref(), Some("xhigh"));
|
||||
assert_eq!(usage.provider_reasoning_effort().as_deref(), Some("max"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_tier_does_not_infer_fast_from_response_or_stale_metadata() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"model": "gpt-5"
|
||||
}));
|
||||
usage.request_metadata = Some(json!({
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "priority"
|
||||
}));
|
||||
usage.response_body = Some(json!({
|
||||
"service_tier": "priority"
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_service_tier(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_tier_uses_derived_metadata_after_final_body_is_stripped() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = None;
|
||||
usage.provider_request_body_state = Some(UsageBodyCaptureState::Disabled);
|
||||
usage.request_metadata = Some(json!({
|
||||
"provider_service_tier": "priority"
|
||||
}));
|
||||
usage.response_body = Some(json!({
|
||||
"service_tier": "default"
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_service_tier().as_deref(), Some("priority"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_tier_uses_request_derived_metadata_after_body_truncation() {
|
||||
let mut usage = sample_usage();
|
||||
usage.provider_request_body = Some(json!({
|
||||
"truncated": true,
|
||||
"reason": "body_capture_limit_exceeded",
|
||||
"max_bytes": 128,
|
||||
"source_bytes": 4096,
|
||||
"value_kind": "object"
|
||||
}));
|
||||
usage.provider_request_body_state = Some(UsageBodyCaptureState::Truncated);
|
||||
usage.request_metadata = Some(json!({
|
||||
"provider_service_tier": "priority"
|
||||
}));
|
||||
usage.response_body = Some(json!({
|
||||
"service_tier": "default"
|
||||
}));
|
||||
|
||||
assert_eq!(usage.provider_service_tier().as_deref(), Some("priority"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_cache_ttl_uses_final_openai_contract_then_preserved_metadata() {
|
||||
let mut usage = sample_usage();
|
||||
|
||||
@@ -14,14 +14,16 @@ use aether_data_contracts::repository::usage::{
|
||||
StoredUsageProviderPerformanceTimelineRow, StoredUsageSettledCostSummary,
|
||||
StoredUsageTimeSeriesBucket, StoredUsageUserTotals, UsageAuditAggregationGroupBy,
|
||||
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditSummaryQuery,
|
||||
UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||
UsageBodyCaptureState, UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
||||
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCostSavingsSummaryQuery,
|
||||
UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
||||
UsageDashboardSummaryQuery, UsageErrorDistributionQuery, UsageLeaderboardGroupBy,
|
||||
UsageLeaderboardQuery, UsageMonitoringErrorCountQuery, UsageMonitoringErrorListQuery,
|
||||
UsagePerformancePercentilesQuery, UsageProviderPerformanceQuery, UsageSettledCostSummaryQuery,
|
||||
UsageTimeSeriesGranularity, UsageTimeSeriesQuery,
|
||||
UsageTimeSeriesGranularity, UsageTimeSeriesQuery, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY,
|
||||
PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
REQUESTED_REASONING_EFFORT_METADATA_KEY,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
@@ -2703,10 +2705,14 @@ fn hydrate_client_family(item: &mut StoredRequestUsageAudit) {
|
||||
fn persisted_usage_body_ref(
|
||||
incoming_ref: Option<&str>,
|
||||
incoming_body: Option<&Value>,
|
||||
incoming_state: Option<UsageBodyCaptureState>,
|
||||
_metadata: Option<&Value>,
|
||||
existing: Option<&StoredRequestUsageAudit>,
|
||||
field: UsageBodyField,
|
||||
) -> Option<String> {
|
||||
if incoming_state == Some(UsageBodyCaptureState::None) {
|
||||
return None;
|
||||
}
|
||||
if incoming_body.is_some() {
|
||||
return None;
|
||||
}
|
||||
@@ -2724,6 +2730,78 @@ fn persisted_usage_body_ref(
|
||||
})
|
||||
}
|
||||
|
||||
fn request_body_capture_replaces_derived_facts(
|
||||
request_body: Option<&Value>,
|
||||
request_body_state: Option<UsageBodyCaptureState>,
|
||||
) -> bool {
|
||||
if request_body_state.is_some() {
|
||||
return true;
|
||||
}
|
||||
let Some(request_body) = request_body else {
|
||||
return false;
|
||||
};
|
||||
!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")
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_request_body_facts(
|
||||
metadata: Option<&Value>,
|
||||
clear_client_request_body_facts: bool,
|
||||
clear_provider_request_body_facts: bool,
|
||||
) -> Value {
|
||||
let mut metadata = metadata
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if clear_client_request_body_facts {
|
||||
metadata.remove(REQUESTED_REASONING_EFFORT_METADATA_KEY);
|
||||
metadata.remove("request_body_ref");
|
||||
}
|
||||
if clear_provider_request_body_facts {
|
||||
metadata.remove(PROVIDER_REASONING_EFFORT_METADATA_KEY);
|
||||
metadata.remove(PROVIDER_SERVICE_TIER_METADATA_KEY);
|
||||
metadata.remove(PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY);
|
||||
metadata.remove("provider_request_body_ref");
|
||||
}
|
||||
Value::Object(metadata)
|
||||
}
|
||||
|
||||
fn retain_previous_request_audit_metadata(
|
||||
metadata: Option<&Value>,
|
||||
preserve_client_request_body_facts: bool,
|
||||
) -> Value {
|
||||
let Some(metadata) = metadata.and_then(Value::as_object) else {
|
||||
return Value::Object(serde_json::Map::new());
|
||||
};
|
||||
let mut retained = serde_json::Map::new();
|
||||
for key in [
|
||||
"trace_id",
|
||||
"client_ip",
|
||||
"user_agent",
|
||||
"client_family",
|
||||
"client_requested_stream",
|
||||
"client_session_affinity",
|
||||
"api_key_is_standalone",
|
||||
"request_path",
|
||||
"request_query_string",
|
||||
"request_path_and_query",
|
||||
] {
|
||||
if let Some(value) = metadata.get(key) {
|
||||
retained.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if preserve_client_request_body_facts {
|
||||
for key in [REQUESTED_REASONING_EFFORT_METADATA_KEY, "request_body_ref"] {
|
||||
if let Some(value) = metadata.get(key) {
|
||||
retained.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(retained)
|
||||
}
|
||||
|
||||
fn merge_usage_status_code(
|
||||
existing: Option<&StoredRequestUsageAudit>,
|
||||
incoming_status: &str,
|
||||
@@ -2789,14 +2867,57 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
return Ok(existing.expect("existing usage should be present").clone());
|
||||
}
|
||||
|
||||
let request_metadata = usage.request_metadata.clone().or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.request_metadata.clone())
|
||||
let replace_client_request_body_facts = request_body_capture_replaces_derived_facts(
|
||||
usage.request_body.as_ref(),
|
||||
usage.request_body_state,
|
||||
);
|
||||
let replace_provider_request_body_facts = request_body_capture_replaces_derived_facts(
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.provider_request_body_state,
|
||||
);
|
||||
let clear_request_body = usage.request_body_state == Some(UsageBodyCaptureState::None);
|
||||
let clear_provider_request_body =
|
||||
usage.provider_request_body_state == Some(UsageBodyCaptureState::None);
|
||||
let clear_response_body = usage.response_body_state == Some(UsageBodyCaptureState::None);
|
||||
let clear_client_response_body =
|
||||
usage.client_response_body_state == Some(UsageBodyCaptureState::None);
|
||||
let replace_routing_snapshot = usage_status_is_finalized(&usage.status);
|
||||
let mut incoming_request_metadata = usage.request_metadata.clone();
|
||||
if incoming_request_metadata.is_some()
|
||||
&& (clear_request_body || clear_provider_request_body)
|
||||
{
|
||||
incoming_request_metadata = Some(clear_request_body_facts(
|
||||
incoming_request_metadata.as_ref(),
|
||||
clear_request_body,
|
||||
clear_provider_request_body,
|
||||
));
|
||||
}
|
||||
let request_metadata = incoming_request_metadata.or_else(|| {
|
||||
if replace_routing_snapshot {
|
||||
Some(retain_previous_request_audit_metadata(
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.request_metadata.as_ref()),
|
||||
!replace_client_request_body_facts,
|
||||
))
|
||||
} else if replace_client_request_body_facts || replace_provider_request_body_facts {
|
||||
Some(clear_request_body_facts(
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.request_metadata.as_ref()),
|
||||
replace_client_request_body_facts,
|
||||
replace_provider_request_body_facts,
|
||||
))
|
||||
} else {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.request_metadata.clone())
|
||||
}
|
||||
});
|
||||
let request_body_ref = persisted_usage_body_ref(
|
||||
usage.request_body_ref.as_deref(),
|
||||
usage.request_body.as_ref(),
|
||||
usage.request_body_state,
|
||||
request_metadata.as_ref(),
|
||||
existing.as_ref(),
|
||||
UsageBodyField::RequestBody,
|
||||
@@ -2804,6 +2925,7 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
let provider_request_body_ref = persisted_usage_body_ref(
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.provider_request_body_state,
|
||||
request_metadata.as_ref(),
|
||||
existing.as_ref(),
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
@@ -2811,6 +2933,7 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
let response_body_ref = persisted_usage_body_ref(
|
||||
usage.response_body_ref.as_deref(),
|
||||
usage.response_body.as_ref(),
|
||||
usage.response_body_state,
|
||||
request_metadata.as_ref(),
|
||||
existing.as_ref(),
|
||||
UsageBodyField::ResponseBody,
|
||||
@@ -2818,10 +2941,34 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
let client_response_body_ref = persisted_usage_body_ref(
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
usage.client_response_body.as_ref(),
|
||||
usage.client_response_body_state,
|
||||
request_metadata.as_ref(),
|
||||
existing.as_ref(),
|
||||
UsageBodyField::ClientResponseBody,
|
||||
);
|
||||
if clear_request_body
|
||||
|| clear_provider_request_body
|
||||
|| clear_response_body
|
||||
|| clear_client_response_body
|
||||
{
|
||||
let mut detached_bodies = self.detached_bodies.write().expect("usage repository lock");
|
||||
for (clear, field) in [
|
||||
(clear_request_body, UsageBodyField::RequestBody),
|
||||
(
|
||||
clear_provider_request_body,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
),
|
||||
(clear_response_body, UsageBodyField::ResponseBody),
|
||||
(
|
||||
clear_client_response_body,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
),
|
||||
] {
|
||||
if clear {
|
||||
detached_bodies.remove(&usage_body_ref(&usage.request_id, field));
|
||||
}
|
||||
}
|
||||
}
|
||||
let stored = StoredRequestUsageAudit {
|
||||
id: existing
|
||||
.as_ref()
|
||||
@@ -2935,11 +3082,15 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.request_headers.clone())
|
||||
}),
|
||||
request_body: usage.request_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.request_body.clone())
|
||||
}),
|
||||
request_body: if clear_request_body {
|
||||
None
|
||||
} else {
|
||||
usage.request_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.request_body.clone())
|
||||
})
|
||||
},
|
||||
request_body_ref,
|
||||
request_body_state: usage.request_body_state.or_else(|| {
|
||||
existing
|
||||
@@ -2951,11 +3102,15 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.provider_request_headers.clone())
|
||||
}),
|
||||
provider_request_body: usage.provider_request_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.provider_request_body.clone())
|
||||
}),
|
||||
provider_request_body: if clear_provider_request_body {
|
||||
None
|
||||
} else {
|
||||
usage.provider_request_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.provider_request_body.clone())
|
||||
})
|
||||
},
|
||||
provider_request_body_ref,
|
||||
provider_request_body_state: usage.provider_request_body_state.or_else(|| {
|
||||
existing
|
||||
@@ -2967,11 +3122,15 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.response_headers.clone())
|
||||
}),
|
||||
response_body: usage.response_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.response_body.clone())
|
||||
}),
|
||||
response_body: if clear_response_body {
|
||||
None
|
||||
} else {
|
||||
usage.response_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.response_body.clone())
|
||||
})
|
||||
},
|
||||
response_body_ref,
|
||||
response_body_state: usage.response_body_state.or_else(|| {
|
||||
existing
|
||||
@@ -2983,61 +3142,95 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.client_response_headers.clone())
|
||||
}),
|
||||
client_response_body: usage.client_response_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.client_response_body.clone())
|
||||
}),
|
||||
client_response_body: if clear_client_response_body {
|
||||
None
|
||||
} else {
|
||||
usage.client_response_body.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.client_response_body.clone())
|
||||
})
|
||||
},
|
||||
client_response_body_ref,
|
||||
client_response_body_state: usage.client_response_body_state.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.client_response_body_state)
|
||||
}),
|
||||
candidate_id: usage.candidate_id.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_candidate_id().map(ToOwned::to_owned))
|
||||
}),
|
||||
candidate_index: usage.candidate_index.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_candidate_index())
|
||||
}),
|
||||
key_name: usage.key_name.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_key_name().map(ToOwned::to_owned))
|
||||
}),
|
||||
planner_kind: usage.planner_kind.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_planner_kind().map(ToOwned::to_owned))
|
||||
}),
|
||||
route_family: usage.route_family.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_route_family().map(ToOwned::to_owned))
|
||||
}),
|
||||
route_kind: usage.route_kind.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_route_kind().map(ToOwned::to_owned))
|
||||
}),
|
||||
execution_path: usage.execution_path.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_execution_path().map(ToOwned::to_owned))
|
||||
}),
|
||||
local_execution_runtime_miss_reason: usage.local_execution_runtime_miss_reason.or_else(
|
||||
|| {
|
||||
candidate_id: if replace_routing_snapshot {
|
||||
usage.candidate_id
|
||||
} else {
|
||||
usage.candidate_id.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_candidate_id().map(ToOwned::to_owned))
|
||||
})
|
||||
},
|
||||
candidate_index: if replace_routing_snapshot {
|
||||
usage.candidate_index
|
||||
} else {
|
||||
usage.candidate_index.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_candidate_index())
|
||||
})
|
||||
},
|
||||
key_name: if replace_routing_snapshot {
|
||||
usage.key_name
|
||||
} else {
|
||||
usage.key_name.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_key_name().map(ToOwned::to_owned))
|
||||
})
|
||||
},
|
||||
planner_kind: if replace_routing_snapshot {
|
||||
usage.planner_kind
|
||||
} else {
|
||||
usage.planner_kind.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_planner_kind().map(ToOwned::to_owned))
|
||||
})
|
||||
},
|
||||
route_family: if replace_routing_snapshot {
|
||||
usage.route_family
|
||||
} else {
|
||||
usage.route_family.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_route_family().map(ToOwned::to_owned))
|
||||
})
|
||||
},
|
||||
route_kind: if replace_routing_snapshot {
|
||||
usage.route_kind
|
||||
} else {
|
||||
usage.route_kind.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|existing| existing.routing_route_kind().map(ToOwned::to_owned))
|
||||
})
|
||||
},
|
||||
execution_path: if replace_routing_snapshot {
|
||||
usage.execution_path
|
||||
} else {
|
||||
usage.execution_path.or_else(|| {
|
||||
existing.as_ref().and_then(|existing| {
|
||||
existing.routing_execution_path().map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
},
|
||||
local_execution_runtime_miss_reason: if replace_routing_snapshot {
|
||||
usage.local_execution_runtime_miss_reason
|
||||
} else {
|
||||
usage.local_execution_runtime_miss_reason.or_else(|| {
|
||||
existing.as_ref().and_then(|existing| {
|
||||
existing
|
||||
.routing_local_execution_runtime_miss_reason()
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
},
|
||||
),
|
||||
})
|
||||
},
|
||||
client_family: usage_request_metadata_client_family(request_metadata.as_ref())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::repository::usage::{
|
||||
};
|
||||
use aether_data_contracts::repository::usage::{
|
||||
usage_body_ref, ProviderApiKeyWindowUsageRequest, UsageAuditAggregationGroupBy,
|
||||
UsageAuditAggregationQuery, UsageBodyField, UsageDashboardSummaryQuery,
|
||||
UsageAuditAggregationQuery, UsageBodyCaptureState, UsageBodyField, UsageDashboardSummaryQuery,
|
||||
UsageLeaderboardGroupBy, UsageLeaderboardQuery, UsageProviderPerformanceQuery,
|
||||
UsageTimeSeriesGranularity,
|
||||
};
|
||||
@@ -135,6 +135,127 @@ fn sample_upsert_usage_record(request_id: &str) -> UpsertUsageRecord {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_uses_typed_provider_capture_as_the_fast_fact_snapshot() {
|
||||
for (name, state, incoming_tier, expected_tier) in [
|
||||
(
|
||||
"disabled-clear",
|
||||
UsageBodyCaptureState::Disabled,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"truncated-clear",
|
||||
UsageBodyCaptureState::Truncated,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"unavailable-clear",
|
||||
UsageBodyCaptureState::Unavailable,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"disabled-preserve",
|
||||
UsageBodyCaptureState::Disabled,
|
||||
Some("priority"),
|
||||
Some("priority"),
|
||||
),
|
||||
(
|
||||
"truncated-preserve",
|
||||
UsageBodyCaptureState::Truncated,
|
||||
Some("priority"),
|
||||
Some("priority"),
|
||||
),
|
||||
(
|
||||
"none-clears-residual",
|
||||
UsageBodyCaptureState::None,
|
||||
Some("priority"),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let request_id = format!("req-memory-fast-{name}");
|
||||
let repository = InMemoryUsageReadRepository::default();
|
||||
let mut pending = sample_upsert_usage_record(&request_id);
|
||||
pending.provider_request_body = Some(json!({
|
||||
"model": "gpt-5",
|
||||
"service_tier": "priority"
|
||||
}));
|
||||
pending.provider_request_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
pending.request_metadata = Some(json!({"provider_service_tier": "priority"}));
|
||||
pending.target_model = Some("candidate-a-target".to_string());
|
||||
pending.candidate_id = Some("candidate-a".to_string());
|
||||
pending.candidate_index = Some(1);
|
||||
pending.key_name = Some("key-a".to_string());
|
||||
pending.planner_kind = Some("planner-a".to_string());
|
||||
pending.route_family = Some("route-family-a".to_string());
|
||||
pending.route_kind = Some("route-kind-a".to_string());
|
||||
pending.execution_path = Some("path-a".to_string());
|
||||
let pending = repository
|
||||
.upsert(pending)
|
||||
.await
|
||||
.expect("pending usage should upsert");
|
||||
assert_eq!(pending.provider_service_tier().as_deref(), Some("priority"));
|
||||
|
||||
let mut terminal = sample_upsert_usage_record(&request_id);
|
||||
terminal.status = "completed".to_string();
|
||||
terminal.provider_request_body_state = Some(state);
|
||||
terminal.target_model = None;
|
||||
terminal.request_metadata =
|
||||
incoming_tier.map(|tier| json!({"provider_service_tier": tier}));
|
||||
terminal.updated_at_unix_secs += 1;
|
||||
terminal.finalized_at_unix_secs = Some(terminal.updated_at_unix_secs);
|
||||
if state == UsageBodyCaptureState::None {
|
||||
terminal.provider_request_body = Some(json!({
|
||||
"model": "stale-model",
|
||||
"service_tier": "priority"
|
||||
}));
|
||||
terminal.provider_request_body_ref = Some(usage_body_ref(
|
||||
&request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
));
|
||||
}
|
||||
let terminal = repository
|
||||
.upsert(terminal)
|
||||
.await
|
||||
.expect("terminal usage should upsert");
|
||||
assert_eq!(
|
||||
terminal.provider_service_tier().as_deref(),
|
||||
expected_tier,
|
||||
"state={state:?} must use only incoming facts"
|
||||
);
|
||||
assert_eq!(terminal.target_model, None);
|
||||
assert_eq!(terminal.candidate_id, None);
|
||||
assert_eq!(terminal.candidate_index, None);
|
||||
assert_eq!(terminal.key_name, None);
|
||||
assert_eq!(terminal.planner_kind, None);
|
||||
assert_eq!(terminal.route_family, None);
|
||||
assert_eq!(terminal.route_kind, None);
|
||||
assert_eq!(terminal.execution_path, None);
|
||||
if state == UsageBodyCaptureState::None {
|
||||
assert_eq!(terminal.provider_request_body, None);
|
||||
assert_eq!(terminal.provider_request_body_ref, None);
|
||||
|
||||
let mut late = sample_upsert_usage_record(&request_id);
|
||||
late.provider_request_body = Some(json!({
|
||||
"model": "late-model",
|
||||
"service_tier": "priority"
|
||||
}));
|
||||
late.provider_request_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
late.request_metadata = Some(json!({"provider_service_tier": "priority"}));
|
||||
late.updated_at_unix_secs += 2;
|
||||
let late = repository
|
||||
.upsert(late)
|
||||
.await
|
||||
.expect("late pending usage should not regress terminal capture");
|
||||
assert_eq!(late.provider_service_tier(), None);
|
||||
assert_eq!(late.provider_request_body, None);
|
||||
assert_eq!(late.provider_request_body_ref, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_usage_by_request_id() {
|
||||
let repository = InMemoryUsageReadRepository::seed(vec![
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -239,6 +239,20 @@ describe('resolveModelsDevTieredPricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the standard catalog when an imported tier has a zero default ratio', () => {
|
||||
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
|
||||
input: 5,
|
||||
output: 30,
|
||||
}, {
|
||||
fast: {
|
||||
cost: { input: 0, output: 0 },
|
||||
provider: { body: { service_tier: 'priority' } },
|
||||
},
|
||||
})?.processing_tiers).toEqual({
|
||||
priority: { price_multiplier: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers Anthropic speed=fast when the mode body also carries a standard service tier', () => {
|
||||
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-fast', {
|
||||
input: 5,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { getProviderKeysPage } from '@/api/endpoints/keys'
|
||||
|
||||
describe('getProviderKeysPage', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes a legacy array response for the provider drawer', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: [{ id: 'key-1' }, { id: 'key-2' }],
|
||||
})
|
||||
|
||||
const result = await getProviderKeysPage('provider-demo', { page: 1, page_size: 1 })
|
||||
|
||||
expect(result).toMatchObject({ total: 2, page: 1, page_size: 1 })
|
||||
expect(result.keys).toEqual([{ id: 'key-1' }])
|
||||
})
|
||||
|
||||
it('normalizes a malformed object without exposing a non-array keys field', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: { total: null, page: null, page_size: null, keys: {} },
|
||||
})
|
||||
|
||||
const result = await getProviderKeysPage('provider-demo', { page: 2, page_size: 3 })
|
||||
|
||||
expect(result).toEqual({ total: 0, page: 2, page_size: 3, keys: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { getProviderMappingPreview, getProvidersSummary } from '@/api/endpoints/providers'
|
||||
|
||||
const provider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
provider_type: 'openai',
|
||||
is_active: true,
|
||||
endpoints: [],
|
||||
}
|
||||
|
||||
describe('getProvidersSummary', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes the paginated summary response', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: { total: 1, page: 1, page_size: 20, items: [provider] },
|
||||
})
|
||||
|
||||
const result = await getProvidersSummary({ page: 1, page_size: 20, search: 'paged' })
|
||||
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.kiro_simulated_cache_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('supports the legacy array response without reading an undefined items field', async () => {
|
||||
getMock.mockResolvedValue({ data: [provider] })
|
||||
|
||||
const result = await getProvidersSummary({ page: 2, page_size: 20, search: 'legacy' })
|
||||
|
||||
expect(result).toMatchObject({ total: 1, page: 2, page_size: 20 })
|
||||
expect(result.items).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProviderMappingPreview', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes a non-contract response instead of exposing missing arrays to the UI', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: { message: '演示模式:该接口暂未模拟', demo_mode: true },
|
||||
})
|
||||
|
||||
const result = await getProviderMappingPreview('provider-demo')
|
||||
|
||||
expect(result).toEqual({
|
||||
provider_id: 'provider-demo',
|
||||
provider_name: '',
|
||||
keys: [],
|
||||
total_keys: 0,
|
||||
total_matches: 0,
|
||||
truncated: false,
|
||||
truncated_keys: 0,
|
||||
truncated_models: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes missing nested mapping arrays', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: {
|
||||
provider_id: 'provider-nested',
|
||||
provider_name: 'Nested Provider',
|
||||
keys: [{
|
||||
key_id: 'key-1',
|
||||
key_name: 'Primary',
|
||||
masked_key: 'sk-***',
|
||||
is_active: true,
|
||||
allowed_models: null,
|
||||
matching_global_models: [{
|
||||
global_model_id: 'model-1',
|
||||
global_model_name: 'gpt-5',
|
||||
display_name: 'GPT-5',
|
||||
is_active: true,
|
||||
matched_models: null,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await getProviderMappingPreview('provider-nested')
|
||||
|
||||
expect(result.keys[0]?.allowed_models).toEqual([])
|
||||
expect(result.keys[0]?.matching_global_models[0]?.matched_models).toEqual([])
|
||||
expect(result.total_keys).toBe(1)
|
||||
expect(result.total_matches).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock, cachedRequestMock } = vi.hoisted(() => ({
|
||||
const { getMock, postMock, cachedRequestMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
postMock: vi.fn(),
|
||||
cachedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -20,6 +22,7 @@ import { usersApi } from '@/api/users'
|
||||
describe('usersApi admin list query', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
cachedRequestMock.mockClear()
|
||||
getMock.mockResolvedValue({
|
||||
data: {
|
||||
@@ -49,4 +52,55 @@ describe('usersApi admin list query', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps user management renderable when the group response has no items array', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
message: '演示模式:该接口暂未模拟',
|
||||
demo_mode: true,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(usersApi.listUserGroups()).resolves.toEqual({
|
||||
message: '演示模式:该接口暂未模拟',
|
||||
demo_mode: true,
|
||||
items: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a managed key through the selected target user route', async () => {
|
||||
postMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'target-key',
|
||||
key: 'sk-target',
|
||||
},
|
||||
})
|
||||
const payload = {
|
||||
name: 'target key',
|
||||
feature_settings: {
|
||||
chat_pii_redaction: { enabled: true },
|
||||
},
|
||||
}
|
||||
|
||||
await usersApi.createApiKey('target-user', payload)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/api/admin/users/target-user/api-keys',
|
||||
payload,
|
||||
)
|
||||
})
|
||||
|
||||
it('reads managed keys from the production api_keys envelope', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
api_keys: [{ id: 'target-key', created_at: '2026-07-17T00:00:00Z' }],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(usersApi.getUserApiKeys('target-user')).resolves.toEqual([
|
||||
{ id: 'target-key', created_at: '2026-07-17T00:00:00Z' },
|
||||
])
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,6 +209,7 @@ export interface RequestDetail {
|
||||
has_format_conversion?: boolean | null
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
@@ -266,6 +267,7 @@ export interface RequestDetail {
|
||||
response_time_ms: number
|
||||
first_byte_time_ms?: number | null
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
provider_request_headers?: Record<string, unknown>
|
||||
|
||||
@@ -121,17 +121,50 @@ export interface ProviderKeysPageQuery {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
type ProviderKeysPagePayload = ProviderKeysPageResponse | EndpointAPIKey[]
|
||||
|
||||
function normalizeProviderKeysPage(
|
||||
value: ProviderKeysPagePayload,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): ProviderKeysPageResponse {
|
||||
if (Array.isArray(value)) {
|
||||
const start = value.length > pageSize ? (page - 1) * pageSize : 0
|
||||
const keys = value.slice(start, start + pageSize)
|
||||
return {
|
||||
total: value.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keys,
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Array.isArray(value.keys) ? value.keys : []
|
||||
return {
|
||||
total: typeof value.total === 'number' && Number.isFinite(value.total)
|
||||
? value.total
|
||||
: keys.length,
|
||||
page: typeof value.page === 'number' && Number.isFinite(value.page)
|
||||
? value.page
|
||||
: page,
|
||||
page_size: typeof value.page_size === 'number' && Number.isFinite(value.page_size)
|
||||
? value.page_size
|
||||
: pageSize,
|
||||
keys,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProviderKeysPage(
|
||||
providerId: string,
|
||||
params: ProviderKeysPageQuery = {},
|
||||
): Promise<ProviderKeysPageResponse> {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.page_size ?? 20
|
||||
const response = await client.get<ProviderKeysPageResponse>(
|
||||
const response = await client.get<ProviderKeysPagePayload>(
|
||||
`/api/admin/endpoints/providers/${providerId}/keys`,
|
||||
{ params: { page, page_size: pageSize } },
|
||||
)
|
||||
return response.data
|
||||
return normalizeProviderKeysPage(response.data, page, pageSize)
|
||||
}
|
||||
|
||||
export async function getProviderKeys(providerId: string): Promise<EndpointAPIKey[]> {
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface ProviderSummaryPageResponse {
|
||||
items: ProviderWithEndpointsSummary[]
|
||||
}
|
||||
|
||||
type ProviderSummaryResponse = ProviderSummaryPageResponse | ProviderWithEndpointsSummary[]
|
||||
|
||||
function normalizeProviderSummary(
|
||||
provider: ProviderWithEndpointsSummary,
|
||||
): ProviderWithEndpointsSummary {
|
||||
@@ -62,16 +64,26 @@ export async function getProvidersSummary(
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await client.get<ProviderSummaryPageResponse>(
|
||||
const response = await client.get<ProviderSummaryResponse>(
|
||||
'/api/admin/providers/summary',
|
||||
{
|
||||
params,
|
||||
timeout: options.timeout,
|
||||
},
|
||||
)
|
||||
const data = response.data
|
||||
if (Array.isArray(data)) {
|
||||
return {
|
||||
total: data.length,
|
||||
page: params.page ?? 1,
|
||||
page_size: params.page_size ?? data.length,
|
||||
items: data.map(normalizeProviderSummary),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
items: response.data.items.map(normalizeProviderSummary),
|
||||
...data,
|
||||
items: (data.items ?? []).map(normalizeProviderSummary),
|
||||
}
|
||||
},
|
||||
cacheTtlMs,
|
||||
@@ -371,6 +383,84 @@ export interface ProviderMappingPreviewResponse {
|
||||
truncated_models: number
|
||||
}
|
||||
|
||||
function mappingPreviewRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
function mappingPreviewString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
function mappingPreviewCount(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: fallback
|
||||
}
|
||||
|
||||
function normalizeProviderMappingPreview(
|
||||
value: unknown,
|
||||
providerId: string,
|
||||
): ProviderMappingPreviewResponse {
|
||||
const source = mappingPreviewRecord(value)
|
||||
const rawKeys = Array.isArray(source.keys) ? source.keys : []
|
||||
const keys = rawKeys.map((rawKey) => {
|
||||
const key = mappingPreviewRecord(rawKey)
|
||||
const rawGlobalModels = Array.isArray(key.matching_global_models)
|
||||
? key.matching_global_models
|
||||
: []
|
||||
|
||||
return {
|
||||
key_id: mappingPreviewString(key.key_id),
|
||||
key_name: mappingPreviewString(key.key_name),
|
||||
masked_key: mappingPreviewString(key.masked_key, '***'),
|
||||
is_active: key.is_active === true,
|
||||
allowed_models: Array.isArray(key.allowed_models)
|
||||
? key.allowed_models.filter((item): item is string => typeof item === 'string')
|
||||
: [],
|
||||
matching_global_models: rawGlobalModels.map((rawGlobalModel) => {
|
||||
const globalModel = mappingPreviewRecord(rawGlobalModel)
|
||||
const rawMatchedModels = Array.isArray(globalModel.matched_models)
|
||||
? globalModel.matched_models
|
||||
: []
|
||||
|
||||
return {
|
||||
global_model_id: mappingPreviewString(globalModel.global_model_id),
|
||||
global_model_name: mappingPreviewString(globalModel.global_model_name),
|
||||
display_name: mappingPreviewString(
|
||||
globalModel.display_name,
|
||||
mappingPreviewString(globalModel.global_model_name),
|
||||
),
|
||||
is_active: globalModel.is_active === true,
|
||||
matched_models: rawMatchedModels.map((rawMatchedModel) => {
|
||||
const matchedModel = mappingPreviewRecord(rawMatchedModel)
|
||||
return {
|
||||
allowed_model: mappingPreviewString(matchedModel.allowed_model),
|
||||
mapping_pattern: mappingPreviewString(matchedModel.mapping_pattern),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
const inferredMatches = keys.reduce(
|
||||
(total, key) => total + key.matching_global_models.length,
|
||||
0,
|
||||
)
|
||||
|
||||
return {
|
||||
provider_id: mappingPreviewString(source.provider_id, providerId),
|
||||
provider_name: mappingPreviewString(source.provider_name),
|
||||
keys,
|
||||
total_keys: mappingPreviewCount(source.total_keys, keys.length),
|
||||
total_matches: mappingPreviewCount(source.total_matches, inferredMatches),
|
||||
truncated: source.truncated === true,
|
||||
truncated_keys: mappingPreviewCount(source.truncated_keys, 0),
|
||||
truncated_models: mappingPreviewCount(source.truncated_models, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Provider 映射预览
|
||||
*/
|
||||
@@ -379,6 +469,6 @@ export async function getProviderMappingPreview(
|
||||
): Promise<ProviderMappingPreviewResponse> {
|
||||
return dedupedRequest(`providers:mapping-preview:${providerId}`, async () => {
|
||||
const response = await client.get<ProviderMappingPreviewResponse>(`/api/admin/providers/${providerId}/mapping-preview`)
|
||||
return response.data
|
||||
return normalizeProviderMappingPreview(response.data, providerId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface UsageRecordDetail {
|
||||
provider?: string // 仅管理员可见
|
||||
model: string
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
@@ -370,6 +371,7 @@ export const meApi = {
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
|
||||
@@ -28,6 +28,7 @@ const TOKEN_PRICE_FIELDS = [
|
||||
'cache_read_price_per_1m',
|
||||
] as const
|
||||
const PROCESSING_MODE_FALLBACK_KEYS = new Set(['fast', 'priority', 'flex', 'batch'])
|
||||
const DEFAULT_PROCESSING_TIER_MULTIPLIER = 1
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -128,7 +129,9 @@ function uniformPriceMultiplier(
|
||||
if (Math.abs(processingPrice - standardPrice * candidate) > 1e-9) return null
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
// A zero ratio from an imported experimental mode is a missing/default price marker,
|
||||
// not a free processing tier. Keep the tier on the Standard catalog so it remains billable.
|
||||
return candidate === 0 ? DEFAULT_PROCESSING_TIER_MULTIPLIER : candidate
|
||||
}
|
||||
|
||||
export function resolveModelsDevTieredPricing(
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UsageRecord {
|
||||
provider_name?: string
|
||||
model: string
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
@@ -570,6 +571,7 @@ export const usageApi = {
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
actual_service_tier?: string | null
|
||||
|
||||
@@ -374,7 +374,10 @@ export const usersApi = {
|
||||
|
||||
async listUserGroups(): Promise<ListUserGroupsResponse> {
|
||||
const response = await apiClient.get<ListUserGroupsResponse>('/api/admin/user-groups')
|
||||
return response.data
|
||||
return {
|
||||
...response.data,
|
||||
items: Array.isArray(response.data?.items) ? response.data.items : [],
|
||||
}
|
||||
},
|
||||
|
||||
async createUserGroup(payload: UpsertUserGroupRequest): Promise<UserGroup> {
|
||||
@@ -417,8 +420,9 @@ export const usersApi = {
|
||||
},
|
||||
|
||||
async getUserApiKeys(userId: string): Promise<ApiKey[]> {
|
||||
const response = await apiClient.get<{ api_keys: ApiKey[] }>(`/api/admin/users/${userId}/api-keys`)
|
||||
return response.data.api_keys
|
||||
const response = await apiClient.get<{ api_keys?: ApiKey[] } | ApiKey[]>(`/api/admin/users/${userId}/api-keys`)
|
||||
if (Array.isArray(response.data)) return response.data
|
||||
return Array.isArray(response.data?.api_keys) ? response.data.api_keys : []
|
||||
},
|
||||
|
||||
async getUserSessions(userId: string): Promise<SessionRecord[]> {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, h, type App } from 'vue'
|
||||
|
||||
import Badge from '../badge.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Badge', () => {
|
||||
it('renders the transparent outline variant without the card background', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(Badge, { variant: 'outline-transparent' }, () => 'Fast'),
|
||||
})
|
||||
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
const badge = root.firstElementChild
|
||||
expect(badge?.classList.contains('border-border')).toBe(true)
|
||||
expect(badge?.classList.contains('bg-transparent')).toBe(true)
|
||||
expect(badge?.classList.contains('bg-card/50')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground border-border bg-card/50',
|
||||
'outline-transparent': 'text-foreground border-border bg-transparent',
|
||||
success:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
warning:
|
||||
@@ -35,7 +36,7 @@ const badgeVariants = cva(
|
||||
)
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'outline-transparent' | 'success' | 'warning' | 'dark'
|
||||
class?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -135,10 +135,10 @@
|
||||
:title="row.statusBadgeTitle"
|
||||
>{{ row.statusBadgeLabel }}</Badge>
|
||||
<Badge
|
||||
v-if="row.key.oauth_plan_type"
|
||||
v-if="row.planLabel"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ row.key.oauth_plan_type }}</Badge>
|
||||
>{{ row.planLabel }}</Badge>
|
||||
<Badge
|
||||
v-if="row.oauthOrgBadge"
|
||||
variant="secondary"
|
||||
@@ -499,6 +499,7 @@ import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import { formatOAuthPlanType } from '@/utils/oauthPlanType'
|
||||
import {
|
||||
canExportOAuthCredential,
|
||||
canRefreshOAuthCredential,
|
||||
@@ -552,6 +553,7 @@ type BatchActionOption = {
|
||||
|
||||
type PageKeyRow = {
|
||||
key: PoolKeyDetail
|
||||
planLabel: string
|
||||
authTypeLabel: string
|
||||
statusBadgeLabel: string | null
|
||||
statusBadgeTitle: string
|
||||
@@ -650,6 +652,7 @@ const pageKeyRows = computed<PageKeyRow[]>(() => pageKeys.value.map((key) => {
|
||||
|
||||
return {
|
||||
key,
|
||||
planLabel: formatOAuthPlanType(key.oauth_plan_type),
|
||||
authTypeLabel: normalizeAuthTypeLabel(key),
|
||||
statusBadgeLabel,
|
||||
statusBadgeTitle: statusBadgeLabel ? getStatusBadgeTitle(key) : '',
|
||||
|
||||
@@ -109,7 +109,10 @@ const QuotaProgressRows = defineComponent({
|
||||
: 'flex flex-col gap-1 min-w-[140px] max-w-[208px]',
|
||||
}, [
|
||||
h('div', { class: 'flex items-center justify-between text-[10px] leading-none' }, [
|
||||
h('span', { class: 'text-muted-foreground font-medium shrink-0' }, item.label),
|
||||
h('span', {
|
||||
'data-testid': 'pool-quota-period-label',
|
||||
class: 'text-muted-foreground font-medium shrink-0',
|
||||
}, item.label),
|
||||
item.resetText
|
||||
? h('span', {
|
||||
'data-testid': 'pool-quota-reset-text',
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="cycle"
|
||||
v-if="cycle && cycleMetricRows.length > 0"
|
||||
:class="cycleContainerClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-groups' : undefined"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-text' : 'pool-mobile-stats-cycle-text'"
|
||||
>
|
||||
<div
|
||||
:class="cycleGridClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-grid' : 'pool-mobile-stats-cycle-grid'"
|
||||
v-for="row in cycleMetricRows"
|
||||
:key="`${row.key}-${variant}-cycle-row`"
|
||||
class="flex items-baseline justify-between gap-3"
|
||||
:title="`${row.label} ${row.valueText}`"
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
{{ row.label }}
|
||||
</span>
|
||||
<span
|
||||
:class="cycleGroupLabelClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-5h' : 'pool-mobile-stats-cycle-group-5h'"
|
||||
>5H</span>
|
||||
<span class="text-center text-muted-foreground/50">|</span>
|
||||
<span
|
||||
:class="cycleGroupLabelClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-weekly' : 'pool-mobile-stats-cycle-group-weekly'"
|
||||
>{{ legacyT('周') }}</span>
|
||||
|
||||
<template
|
||||
v-for="row in cycleRows"
|
||||
:key="`${row.key}-${variant}-cycle-row`"
|
||||
class="grid w-[112px] shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-baseline gap-x-1 font-medium text-foreground"
|
||||
:data-testid="variant === 'desktop' ? `pool-stats-cycle-${row.key}` : undefined"
|
||||
>
|
||||
<span class="text-muted-foreground truncate">{{ row.label }}</span>
|
||||
<span class="min-w-0 truncate text-right">{{ row.hasComparison ? row.smallValue : '-' }}</span>
|
||||
<span
|
||||
:class="[cycleValueClass, row.fiveH.missing ? 'text-muted-foreground/80' : '']"
|
||||
:data-testid="variant === 'desktop' ? `pool-stats-5h-${row.key}` : undefined"
|
||||
:title="row.fiveH.value"
|
||||
>{{ row.fiveH.value }}</span>
|
||||
<span class="text-center text-muted-foreground/50">|</span>
|
||||
<span
|
||||
:class="[cycleValueClass, row.weekly.missing ? 'text-muted-foreground/80' : '']"
|
||||
:data-testid="variant === 'desktop' ? `pool-stats-weekly-${row.key}` : undefined"
|
||||
:title="row.weekly.value"
|
||||
>{{ row.weekly.value }}</span>
|
||||
</template>
|
||||
class="w-1.5 text-center text-muted-foreground/60"
|
||||
data-cycle-stat-part="divider"
|
||||
aria-hidden="true"
|
||||
>/</span>
|
||||
<span class="min-w-0 truncate text-left">{{ row.largeValue }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="cycle"
|
||||
:class="cycleContainerClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-empty' : 'pool-mobile-stats-cycle-empty'"
|
||||
>
|
||||
<div class="flex min-h-16 items-center justify-center text-muted-foreground">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,47 +67,70 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { PoolStatsMetric } from '@/features/pool/utils/poolStatsDisplay'
|
||||
|
||||
export interface PoolKeyCycleStatsRow {
|
||||
key: PoolStatsMetric['key']
|
||||
label: string
|
||||
fiveH: PoolStatsMetric
|
||||
weekly: PoolStatsMetric
|
||||
}
|
||||
import type {
|
||||
PoolCodexCycleStatsGroup,
|
||||
PoolStatsMetric,
|
||||
PoolStatsMetricKey,
|
||||
} from '@/features/pool/utils/poolStatsDisplay'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
cycle: boolean
|
||||
cycleRows: PoolKeyCycleStatsRow[]
|
||||
cycleGroups: PoolCodexCycleStatsGroup[]
|
||||
accountMetrics: PoolStatsMetric[]
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
const CYCLE_METRIC_KEYS: PoolStatsMetricKey[] = ['request_count', 'total_tokens', 'total_cost_usd']
|
||||
const CYCLE_METRIC_LABELS: Record<PoolStatsMetricKey, string> = {
|
||||
request_count: '请求',
|
||||
total_tokens: 'Token',
|
||||
total_cost_usd: '费用',
|
||||
}
|
||||
|
||||
const cycleContainerClass = computed(() => props.variant === 'desktop'
|
||||
? 'mx-auto w-[188px] text-[10px] leading-4'
|
||||
: ''
|
||||
)
|
||||
function missingMetric(key: PoolStatsMetricKey): PoolStatsMetric {
|
||||
return {
|
||||
key,
|
||||
label: CYCLE_METRIC_LABELS[key],
|
||||
value: '-',
|
||||
missing: true,
|
||||
numericValue: null,
|
||||
}
|
||||
}
|
||||
|
||||
const cycleGridClass = computed(() => [
|
||||
'grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1',
|
||||
props.variant === 'mobile' ? 'text-left' : '',
|
||||
function metricForGroup(
|
||||
group: PoolCodexCycleStatsGroup | undefined,
|
||||
key: PoolStatsMetricKey,
|
||||
): PoolStatsMetric {
|
||||
return group?.metrics.find(metric => metric.key === key) ?? missingMetric(key)
|
||||
}
|
||||
|
||||
const cycleMetricRows = computed(() => {
|
||||
const smallGroup = props.cycleGroups.length > 1 ? props.cycleGroups[0] : undefined
|
||||
const largeGroup = props.cycleGroups.at(-1)
|
||||
if (!largeGroup) return []
|
||||
|
||||
return CYCLE_METRIC_KEYS.map((key) => {
|
||||
const smallMetric = metricForGroup(smallGroup, key)
|
||||
const largeMetric = metricForGroup(largeGroup, key)
|
||||
const hasComparison = Boolean(smallGroup)
|
||||
return {
|
||||
key,
|
||||
label: CYCLE_METRIC_LABELS[key],
|
||||
hasComparison,
|
||||
smallValue: smallMetric.value,
|
||||
largeValue: largeMetric.value,
|
||||
valueText: hasComparison ? `${smallMetric.value}/${largeMetric.value}` : largeMetric.value,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const cycleContainerClass = computed(() => [
|
||||
'w-full space-y-1 text-[11px] leading-4 tabular-nums',
|
||||
props.variant === 'desktop' ? 'mx-auto max-w-[168px]' : 'py-0.5',
|
||||
].filter(Boolean).join(' '))
|
||||
|
||||
const cycleGroupLabelClass = computed(() => props.variant === 'desktop'
|
||||
? 'text-center text-[9px] font-semibold text-muted-foreground/80'
|
||||
: 'text-center text-[10px] font-semibold text-foreground'
|
||||
)
|
||||
|
||||
const cycleValueClass = computed(() => [
|
||||
'min-w-0 truncate text-center text-foreground/90',
|
||||
props.variant === 'desktop' ? 'tabular-nums' : 'font-medium tabular-nums',
|
||||
].join(' '))
|
||||
|
||||
const accountContainerClass = computed(() => props.variant === 'desktop'
|
||||
? 'grid min-h-16 w-[188px] grid-rows-4 gap-0 mx-auto text-[10px] leading-4'
|
||||
: ''
|
||||
|
||||
@@ -11,20 +11,41 @@ describe('pool key display panels', () => {
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(PoolKeyStatsPanel, {
|
||||
cycle: true,
|
||||
cycleRows: [{
|
||||
key: 'request_count',
|
||||
label: '请求',
|
||||
fiveH: { key: 'request_count', label: '请求', value: '12', missing: false },
|
||||
weekly: { key: 'request_count', label: '请求', value: '88', missing: false },
|
||||
}],
|
||||
cycleGroups: [
|
||||
{
|
||||
code: '5h',
|
||||
label: '5H',
|
||||
metrics: [{ key: 'request_count', label: '请求', value: '12', missing: false, numericValue: 12 }],
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
metrics: [{ key: 'request_count', label: '请求', value: '88', missing: false, numericValue: 88 }],
|
||||
},
|
||||
],
|
||||
accountMetrics: [],
|
||||
})
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')).toBeTruthy()
|
||||
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.textContent).toBe('12')
|
||||
expect(root.querySelector('[data-testid="pool-stats-weekly-request_count"]')?.textContent).toBe('88')
|
||||
const stats = root.querySelector('[data-testid="pool-stats-cycle-text"]')
|
||||
const requestValue = root.querySelector('[data-testid="pool-stats-cycle-request_count"]')
|
||||
expect(stats).toBeTruthy()
|
||||
expect(stats?.className).toContain('w-full')
|
||||
expect(stats?.className).toContain('max-w-[168px]')
|
||||
expect(requestValue?.textContent?.trim()).toBe('12/88')
|
||||
expect(requestValue?.previousElementSibling?.textContent?.trim()).toBe('请求')
|
||||
expect(requestValue?.parentElement?.className).toContain('justify-between')
|
||||
expect(requestValue?.className).toContain('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
|
||||
expect(requestValue?.className).toContain('w-[112px]')
|
||||
expect(requestValue?.children[0]?.className).toContain('text-right')
|
||||
expect(requestValue?.children[1]?.textContent).toBe('/')
|
||||
expect(requestValue?.children[2]?.className).toContain('text-left')
|
||||
expect(root.querySelectorAll('[data-cycle-stat-part="divider"]')).toHaveLength(3)
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-small-overlay"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-large-base"]')).toBeNull()
|
||||
expect(root.textContent).not.toContain('5H')
|
||||
expect(root.textContent).not.toContain('周')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
@@ -68,4 +89,40 @@ describe('pool key display panels', () => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('renders single-cycle stats as plain text', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(PoolKeyStatsPanel, {
|
||||
cycle: true,
|
||||
cycleGroups: [{
|
||||
code: 'monthly',
|
||||
label: '月',
|
||||
metrics: [
|
||||
{ key: 'request_count', label: '请求', value: '31', missing: false, numericValue: 31 },
|
||||
{ key: 'total_tokens', label: 'Token', value: '38.8K', missing: false, numericValue: 38_800 },
|
||||
{ key: 'total_cost_usd', label: '费用', value: '$0.077', missing: false, numericValue: 0.077 },
|
||||
],
|
||||
}],
|
||||
accountMetrics: [],
|
||||
})
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
|
||||
const requestValue = root.querySelector('[data-testid="pool-stats-cycle-request_count"]')
|
||||
expect(requestValue?.textContent?.trim()).toBe('-/31')
|
||||
expect(requestValue?.previousElementSibling?.textContent?.trim()).toBe('请求')
|
||||
expect(requestValue?.className).toContain('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
|
||||
expect(requestValue?.children[0]?.textContent).toBe('-')
|
||||
expect(requestValue?.children[1]?.textContent).toBe('/')
|
||||
expect(requestValue?.children[1]?.className).toContain('w-1.5')
|
||||
expect(requestValue?.children[2]?.textContent).toBe('31')
|
||||
expect(requestValue?.children[2]?.className).toContain('text-left')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-bar-request_count"]')).toBeNull()
|
||||
expect(root.textContent).not.toContain('月')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ function createCodexKey(overrides: Partial<PoolStatsKeyInput> = {}): PoolStatsKe
|
||||
windows: [
|
||||
{
|
||||
code: '5h',
|
||||
window_minutes: 300,
|
||||
usage: {
|
||||
request_count: 5,
|
||||
total_tokens: 2500,
|
||||
@@ -27,10 +28,11 @@ function createCodexKey(overrides: Partial<PoolStatsKeyInput> = {}): PoolStatsKe
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
window_minutes: 10_080,
|
||||
usage: {
|
||||
request_count: 0,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: '0.00000000',
|
||||
request_count: 8,
|
||||
total_tokens: 5000,
|
||||
total_cost_usd: '0.012',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -54,9 +56,9 @@ describe('poolStatsDisplay', () => {
|
||||
total_cost_usd: '$0.0045',
|
||||
})
|
||||
expect(metricValues(display.groups[1].metrics)).toEqual({
|
||||
request_count: '0',
|
||||
total_tokens: '0',
|
||||
total_cost_usd: '0',
|
||||
request_count: '8',
|
||||
total_tokens: '5K',
|
||||
total_cost_usd: '$0.012',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +67,7 @@ describe('poolStatsDisplay', () => {
|
||||
createCodexKey({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
windows: [{ code: '5h', usage: null }],
|
||||
windows: [{ code: '5h', window_minutes: 300, usage: null }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -81,10 +83,48 @@ describe('poolStatsDisplay', () => {
|
||||
total_tokens: '—',
|
||||
total_cost_usd: '—',
|
||||
})
|
||||
expect(metricValues(display.groups[1].metrics)).toEqual({
|
||||
request_count: '—',
|
||||
total_tokens: '—',
|
||||
total_cost_usd: '—',
|
||||
expect(display.groups).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('builds monthly stats from the actual quota window and ignores zero placeholders', () => {
|
||||
const display = buildPoolStatsDisplay(
|
||||
createCodexKey({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
windows: [
|
||||
{
|
||||
code: 'monthly',
|
||||
label: '月',
|
||||
window_minutes: 43_800,
|
||||
usage: {
|
||||
request_count: 12,
|
||||
total_tokens: 3456,
|
||||
total_cost_usd: '0.125',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
window_minutes: 0,
|
||||
usage: {
|
||||
request_count: 99,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
'codex',
|
||||
'current_cycle',
|
||||
)
|
||||
|
||||
expect(display.kind).toBe('codex_cycle')
|
||||
if (display.kind !== 'codex_cycle') throw new Error('expected codex cycle display')
|
||||
expect(display.groups.map(group => group.label)).toEqual(['月'])
|
||||
expect(metricValues(display.groups[0].metrics)).toEqual({
|
||||
request_count: '12',
|
||||
total_tokens: '3.5K',
|
||||
total_cost_usd: '$0.125',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { QuotaWindowUsageSnapshot } from '@/api/endpoints/types/statusSnapshot'
|
||||
import type { PoolManagementStatsMode } from '@/features/pool/utils/poolManagementState'
|
||||
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
|
||||
export type PoolStatsMetricKey = 'request_count' | 'total_tokens' | 'total_cost_usd'
|
||||
export type PoolStatsDisplayKind = 'account_total' | 'codex_cycle'
|
||||
export type PoolCodexCycleWindowCode = '5h' | 'weekly'
|
||||
export type PoolCodexCycleWindowCode = string
|
||||
|
||||
export interface PoolStatsKeyInput {
|
||||
request_count?: number | null
|
||||
@@ -14,6 +15,9 @@ export interface PoolStatsKeyInput {
|
||||
quota?: {
|
||||
windows?: Array<{
|
||||
code?: string | null
|
||||
label?: string | null
|
||||
scope?: string | null
|
||||
window_minutes?: number | null
|
||||
usage?: QuotaWindowUsageSnapshot | null
|
||||
} | null> | null
|
||||
} | null
|
||||
@@ -25,6 +29,7 @@ export interface PoolStatsMetric {
|
||||
label: string
|
||||
value: string
|
||||
missing: boolean
|
||||
numericValue?: number | null
|
||||
}
|
||||
|
||||
export interface PoolAccountTotalStatsDisplay {
|
||||
@@ -46,10 +51,6 @@ export interface PoolCodexCycleStatsDisplay {
|
||||
export type PoolStatsDisplay = PoolAccountTotalStatsDisplay | PoolCodexCycleStatsDisplay
|
||||
|
||||
const MISSING_STAT_VALUE = '—'
|
||||
const CODEX_CYCLE_WINDOWS: Array<{ code: PoolCodexCycleWindowCode, label: string }> = [
|
||||
{ code: '5h', label: '5H' },
|
||||
{ code: 'weekly', label: '周' },
|
||||
]
|
||||
|
||||
export function isCodexProviderType(providerType: string | null | undefined): boolean {
|
||||
return String(providerType || '').trim().toLowerCase() === 'codex'
|
||||
@@ -103,12 +104,14 @@ function createMetric(
|
||||
key: PoolStatsMetricKey,
|
||||
label: string,
|
||||
value: string | null,
|
||||
numericValue?: number | null,
|
||||
): PoolStatsMetric {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
value: value ?? MISSING_STAT_VALUE,
|
||||
missing: value == null,
|
||||
numericValue: numericValue ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,17 +119,40 @@ function normalizeWindowCode(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function getQuotaWindowUsage(
|
||||
key: PoolStatsKeyInput,
|
||||
code: PoolCodexCycleWindowCode,
|
||||
): QuotaWindowUsageSnapshot | null {
|
||||
function getCodexCycleStatsGroups(key: PoolStatsKeyInput): PoolCodexCycleStatsGroup[] {
|
||||
const windows = key.status_snapshot?.quota?.windows
|
||||
if (!Array.isArray(windows)) return null
|
||||
if (!Array.isArray(windows)) return []
|
||||
|
||||
const window = windows.find(item => normalizeWindowCode(item?.code) === code)
|
||||
return window?.usage ?? null
|
||||
const seenCodes = new Set<string>()
|
||||
return windows
|
||||
.map((window) => {
|
||||
if (!window) return null
|
||||
const code = normalizeWindowCode(window.code)
|
||||
const scope = String(window.scope || 'account').trim().toLowerCase()
|
||||
if (!code || scope !== 'account' || code.startsWith('spark_') || seenCodes.has(code)) {
|
||||
return null
|
||||
}
|
||||
const presentation = getCodexQuotaWindowPresentation({
|
||||
code,
|
||||
label: window.label,
|
||||
scope,
|
||||
window_minutes: window.window_minutes,
|
||||
})
|
||||
if (!presentation) return null
|
||||
seenCodes.add(code)
|
||||
return {
|
||||
code,
|
||||
label: presentation.label,
|
||||
sortOrder: presentation.sortOrder,
|
||||
metrics: buildCycleMetrics(window.usage ?? null),
|
||||
}
|
||||
})
|
||||
.filter((group): group is PoolCodexCycleStatsGroup & { sortOrder: number } => group != null)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map(({ sortOrder: _sortOrder, ...group }) => group)
|
||||
}
|
||||
|
||||
|
||||
function buildAccountTotalMetrics(key: PoolStatsKeyInput): PoolStatsMetric[] {
|
||||
return [
|
||||
createMetric('request_count', '请求', formatPoolStatInteger(key.request_count)),
|
||||
@@ -136,10 +162,28 @@ function buildAccountTotalMetrics(key: PoolStatsKeyInput): PoolStatsMetric[] {
|
||||
}
|
||||
|
||||
function buildCycleMetrics(usage: QuotaWindowUsageSnapshot | null): PoolStatsMetric[] {
|
||||
const requestCount = usage?.request_count == null ? null : Number(usage.request_count)
|
||||
const totalTokens = usage?.total_tokens == null ? null : Number(usage.total_tokens)
|
||||
const totalCostUsd = usage?.total_cost_usd == null ? null : Number(usage.total_cost_usd)
|
||||
return [
|
||||
createMetric('request_count', '请求', formatCycleInteger(usage?.request_count)),
|
||||
createMetric('total_tokens', 'Token', formatCycleTokenCount(usage?.total_tokens)),
|
||||
createMetric('total_cost_usd', '费用', formatCycleUsd(usage?.total_cost_usd)),
|
||||
createMetric(
|
||||
'request_count',
|
||||
'请求',
|
||||
formatCycleInteger(usage?.request_count),
|
||||
Number.isFinite(requestCount) ? Math.max(requestCount ?? 0, 0) : null,
|
||||
),
|
||||
createMetric(
|
||||
'total_tokens',
|
||||
'Token',
|
||||
formatCycleTokenCount(usage?.total_tokens),
|
||||
Number.isFinite(totalTokens) ? Math.max(totalTokens ?? 0, 0) : null,
|
||||
),
|
||||
createMetric(
|
||||
'total_cost_usd',
|
||||
'费用',
|
||||
formatCycleUsd(usage?.total_cost_usd),
|
||||
Number.isFinite(totalCostUsd) ? Math.max(totalCostUsd ?? 0, 0) : null,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -157,10 +201,7 @@ export function buildCodexCycleStatsDisplay(
|
||||
): PoolCodexCycleStatsDisplay {
|
||||
return {
|
||||
kind: 'codex_cycle',
|
||||
groups: CODEX_CYCLE_WINDOWS.map(window => ({
|
||||
...window,
|
||||
metrics: buildCycleMetrics(getQuotaWindowUsage(key, window.code)),
|
||||
})),
|
||||
groups: getCodexCycleStatsGroups(key),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -782,7 +782,7 @@
|
||||
:models="providerModels"
|
||||
:endpoints="endpoints"
|
||||
:provider-keys="providerKeys"
|
||||
:loading="loadingProviderModels || loadingProviderKeys"
|
||||
:loading="loadingProviderModels"
|
||||
@edit-model="handleEditModel"
|
||||
@batch-assign="handleBatchAssign"
|
||||
@refresh="loadEndpoints"
|
||||
@@ -798,7 +798,7 @@
|
||||
:provider-keys="providerKeys"
|
||||
:models="providerModels"
|
||||
:mapping-preview="providerMappingPreview"
|
||||
:loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"
|
||||
:loading="loadingProviderMappingPreview"
|
||||
@refresh="handleModelMappingChanged"
|
||||
/>
|
||||
</div>
|
||||
@@ -1303,8 +1303,6 @@ watch(
|
||||
loading.value = false
|
||||
}
|
||||
void loadSystemFormatConversionConfig()
|
||||
// mapping-preview 较慢,不阻塞首屏渲染
|
||||
void loadMappingPreview()
|
||||
if (!hasInitialProvider) {
|
||||
await loadProvider()
|
||||
}
|
||||
@@ -1313,7 +1311,13 @@ watch(
|
||||
if (newOpen && !oldOpen) {
|
||||
startCountdownTimer()
|
||||
}
|
||||
void endpointsPromise.then(() => autoRefreshQuotaInBackground())
|
||||
// 优先完成端点、密钥和模型的首屏数据,再请求计算量较大的映射预览。
|
||||
// 同时校验抽屉状态,避免关闭或切换 Provider 后启动无用请求。
|
||||
void endpointsPromise.then(() => {
|
||||
if (!props.open || props.providerId !== newId) return
|
||||
void loadMappingPreview()
|
||||
void autoRefreshQuotaInBackground()
|
||||
})
|
||||
} else if (!newOpen && oldOpen) {
|
||||
// 使在途请求失效,避免关闭后旧响应回写
|
||||
providerLoadRequestId += 1
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from '@vue/server-renderer'
|
||||
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import ModelMappingTab from '../provider-tabs/ModelMappingTab.vue'
|
||||
|
||||
const provider = {
|
||||
id: 'provider-demo',
|
||||
name: 'Demo Provider',
|
||||
provider_type: 'custom',
|
||||
is_active: true,
|
||||
active_keys: 0,
|
||||
api_formats: [],
|
||||
} as ProviderWithEndpointsSummary
|
||||
|
||||
describe('ModelMappingTab response contracts', () => {
|
||||
it('keeps the module visible when a legacy or malformed preview reaches the component', async () => {
|
||||
const app = createSSRApp({
|
||||
render: () => h(ModelMappingTab, {
|
||||
provider,
|
||||
models: [],
|
||||
endpoints: [],
|
||||
providerKeys: [],
|
||||
mappingPreview: {
|
||||
message: '演示模式:该接口暂未模拟',
|
||||
demo_mode: true,
|
||||
},
|
||||
loading: false,
|
||||
}),
|
||||
})
|
||||
|
||||
const html = await renderToString(app)
|
||||
|
||||
expect(html).toContain('模型映射')
|
||||
expect(html).toContain('暂无模型映射')
|
||||
})
|
||||
})
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/features/providers/components/ProviderDetailDrawer.vue'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('ProviderDetailDrawer loading priorities', () => {
|
||||
it('loads mapping preview after first-screen provider data', () => {
|
||||
const openWatcher = source
|
||||
.split('// 合并监听 providerId 和 open')[1]
|
||||
?.split('} else if (!newOpen && oldOpen)')[0]
|
||||
|
||||
expect(openWatcher).toBeTruthy()
|
||||
expect(openWatcher).toContain('const endpointsPromise = loadEndpoints()')
|
||||
expect(openWatcher).toContain('endpointsPromise.then(() => {')
|
||||
expect(openWatcher).toContain('if (!props.open || props.providerId !== newId) return')
|
||||
expect(openWatcher).toContain('void loadMappingPreview()')
|
||||
|
||||
const beforeEndpoints = openWatcher?.split('const endpointsPromise = loadEndpoints()')[0]
|
||||
expect(beforeEndpoints).not.toContain('loadMappingPreview()')
|
||||
})
|
||||
|
||||
it('keeps model and mapping loading states independent', () => {
|
||||
expect(source).toContain(':loading="loadingProviderModels"')
|
||||
expect(source).toContain(':loading="loadingProviderMappingPreview"')
|
||||
expect(source).not.toContain(':loading="loadingProviderModels || loadingProviderKeys"')
|
||||
expect(source).not.toContain(':loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"')
|
||||
})
|
||||
})
|
||||
@@ -599,13 +599,20 @@ const regexMappings = computed<CombinedMapping[]>(() => {
|
||||
const result: CombinedMapping[] = []
|
||||
const modelMap = new Map<string, CombinedMapping>()
|
||||
|
||||
for (const keyInfo of aliasMappingPreview.value.keys) {
|
||||
for (const gm of keyInfo.matching_global_models) {
|
||||
const previewKeys = Array.isArray(aliasMappingPreview.value.keys)
|
||||
? aliasMappingPreview.value.keys
|
||||
: []
|
||||
for (const keyInfo of previewKeys) {
|
||||
const matchingGlobalModels = Array.isArray(keyInfo.matching_global_models)
|
||||
? keyInfo.matching_global_models
|
||||
: []
|
||||
for (const gm of matchingGlobalModels) {
|
||||
const matchedModels = Array.isArray(gm.matched_models) ? gm.matched_models : []
|
||||
if (!modelMap.has(gm.global_model_id)) {
|
||||
modelMap.set(gm.global_model_id, {
|
||||
key: `regex-${gm.global_model_id}`,
|
||||
type: 'regex',
|
||||
targetModelName: gm.display_name,
|
||||
targetModelName: gm.display_name || gm.global_model_name || gm.global_model_id,
|
||||
targetModelId: gm.global_model_id,
|
||||
globalModelName: gm.global_model_name,
|
||||
mappings: [],
|
||||
@@ -618,7 +625,7 @@ const regexMappings = computed<CombinedMapping[]>(() => {
|
||||
if (!mapping) continue
|
||||
|
||||
// 添加 Key 信息
|
||||
const keyMatches: MappingItem[] = gm.matched_models.map(m => ({
|
||||
const keyMatches: MappingItem[] = matchedModels.map(m => ({
|
||||
name: m.allowed_model,
|
||||
pattern: m.mapping_pattern
|
||||
}))
|
||||
@@ -631,7 +638,7 @@ const regexMappings = computed<CombinedMapping[]>(() => {
|
||||
})
|
||||
|
||||
// 收集所有映射(去重)
|
||||
for (const match of gm.matched_models) {
|
||||
for (const match of matchedModels) {
|
||||
if (!mapping.mappings.some(m => m.name === match.allowed_model)) {
|
||||
mapping.mappings.push({
|
||||
name: match.allowed_model,
|
||||
|
||||
@@ -22,6 +22,9 @@ const props = withDefaults(defineProps<{
|
||||
const now = ref(Date.now())
|
||||
const precision = computed(() => Math.max(0, props.precision))
|
||||
const isActive = computed(() => props.status === 'pending' || props.status === 'streaming')
|
||||
// Usage timestamps have second precision while durations have millisecond precision.
|
||||
// Switching anchors can therefore introduce a sub-second phase shift at first byte.
|
||||
const ACTIVE_CLOCK_TIMESTAMP_PRECISION_MS = 1000
|
||||
|
||||
let rafId: number | null = null
|
||||
|
||||
@@ -72,19 +75,29 @@ const displayText = computed(() => {
|
||||
return `${(responseTimeMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
||||
const createdAtElapsedMs = Number.isNaN(createdAtMs)
|
||||
? null
|
||||
: Math.max(0, now.value - createdAtMs)
|
||||
|
||||
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
|
||||
const updatedAtMs = parseCreatedAtMs(props.responseTimeUpdatedAt)
|
||||
if (responseTimeMs != null && !Number.isNaN(updatedAtMs)) {
|
||||
const elapsedSinceUpdateMs = Math.max(0, now.value - updatedAtMs)
|
||||
return `${((responseTimeMs + elapsedSinceUpdateMs) / 1000).toFixed(precision.value)}s`
|
||||
const responseElapsedMs = responseTimeMs + elapsedSinceUpdateMs
|
||||
|
||||
// When both clocks differ only by timestamp truncation, keep the original
|
||||
// created-at clock so the first-byte snapshot cannot make total time pause
|
||||
// or move backwards. A larger difference is a real calibration signal
|
||||
// (for example an audit row created before execution) and remains authoritative.
|
||||
if (createdAtElapsedMs != null &&
|
||||
Math.abs(responseElapsedMs - createdAtElapsedMs) <= ACTIVE_CLOCK_TIMESTAMP_PRECISION_MS) {
|
||||
return `${(createdAtElapsedMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
return `${(responseElapsedMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
if (!props.createdAt) return '-'
|
||||
|
||||
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
||||
if (Number.isNaN(createdAtMs)) return '-'
|
||||
|
||||
const elapsedMs = Math.max(0, now.value - createdAtMs)
|
||||
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
|
||||
if (createdAtElapsedMs == null) return '-'
|
||||
return `${(createdAtElapsedMs / 1000).toFixed(precision.value)}s`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息:真实上游响应合并在此处展示 -->
|
||||
<!-- 错误信息:将实际上游响应头和响应体作为同一个对象展示 -->
|
||||
<div
|
||||
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
|
||||
class="error-block"
|
||||
@@ -485,12 +485,24 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRequestError.upstreamResponse"
|
||||
class="error-json"
|
||||
class="error-json error-upstream-response-json"
|
||||
>
|
||||
<JsonContentPanel
|
||||
:data="currentAttemptRequestError.upstreamResponse"
|
||||
:is-dark="isDark"
|
||||
empty-message="无上游响应信息"
|
||||
title="上游响应"
|
||||
empty-message="无上游响应"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRequestError.diagnostic"
|
||||
class="error-json error-diagnostic-json"
|
||||
>
|
||||
<JsonContentPanel
|
||||
:data="currentAttemptRequestError.diagnostic"
|
||||
:is-dark="isDark"
|
||||
title="失败诊断"
|
||||
empty-message="无失败诊断信息"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1227,7 +1239,7 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
|
||||
const raw = extractObject(value)
|
||||
if (!raw) return null
|
||||
const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode')
|
||||
const headers = raw.headers
|
||||
const headers = raw.headers ?? raw.header
|
||||
const body = raw.body
|
||||
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
|
||||
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
|
||||
@@ -1627,6 +1639,7 @@ const currentAttemptRequestError = computed<{
|
||||
message: string
|
||||
statusCode?: number
|
||||
upstreamResponse: Record<string, unknown> | null
|
||||
diagnostic: Record<string, unknown> | null
|
||||
} | null>(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt || attempt.status !== 'failed') return null
|
||||
@@ -1667,10 +1680,20 @@ const currentAttemptRequestError = computed<{
|
||||
rawMessage,
|
||||
)
|
||||
: null
|
||||
const upstreamResponseWithDiagnostic = diagnostic
|
||||
? { ...(upstreamResponseDisplay ?? {}), diagnostic }
|
||||
: upstreamResponseDisplay
|
||||
if (!message && statusCode == null && !upstreamResponseWithDiagnostic) return null
|
||||
const upstreamResponseData: Record<string, unknown> = {}
|
||||
const responseHeader = upstreamResponseDisplay?.headers
|
||||
const responseBody = upstreamResponseDisplay?.body
|
||||
if (hasRenderableValue(responseHeader)) upstreamResponseData.header = responseHeader
|
||||
if (hasRenderableValue(responseBody)) upstreamResponseData.body = responseBody
|
||||
const response = Object.keys(upstreamResponseData).length > 0
|
||||
? upstreamResponseData
|
||||
: null
|
||||
if (
|
||||
!message
|
||||
&& statusCode == null
|
||||
&& !response
|
||||
&& !diagnostic
|
||||
) return null
|
||||
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
|
||||
rawMessage || fallbackType,
|
||||
upstreamResponseDisplay,
|
||||
@@ -1679,7 +1702,8 @@ const currentAttemptRequestError = computed<{
|
||||
return {
|
||||
message: showMessage ? (message || '未知错误') : '',
|
||||
statusCode,
|
||||
upstreamResponse: upstreamResponseWithDiagnostic,
|
||||
upstreamResponse: response,
|
||||
diagnostic,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -23,23 +23,21 @@
|
||||
<h3 class="text-lg font-semibold">
|
||||
请求详情
|
||||
</h3>
|
||||
<div class="flex min-w-0 max-w-[10rem] items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded sm:max-w-none">
|
||||
<span class="truncate">{{ detail?.model || '-' }}</span>
|
||||
<template v-if="detail?.target_model && detail.target_model !== detail.model">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{{ detail.target_model }}</span>
|
||||
</template>
|
||||
<UsageModelDisplay
|
||||
v-if="headerModelRecord"
|
||||
:record="headerModelRecord"
|
||||
:cyber="detailCyberPolicyError"
|
||||
context="detail"
|
||||
data-request-detail-model-display
|
||||
class="min-w-0 max-w-[18rem] text-sm font-mono text-muted-foreground sm:max-w-none"
|
||||
model-row-class="rounded bg-muted px-2 py-0.5"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
data-request-detail-model-display
|
||||
class="rounded bg-muted px-2 py-0.5 text-sm font-mono text-muted-foreground"
|
||||
>
|
||||
-
|
||||
</div>
|
||||
<Badge
|
||||
v-if="detail?.status_code === 200"
|
||||
@@ -159,47 +157,6 @@
|
||||
v-else-if="detail"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- 执行失败原因:优先展示本地调度/运行时失败摘要 -->
|
||||
<Card
|
||||
v-if="failureNotice"
|
||||
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
|
||||
>
|
||||
<div class="p-3 sm:p-4 flex gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
|
||||
{{ failureNotice.title }}
|
||||
</h4>
|
||||
<Badge
|
||||
v-if="failureNotice.isSchedulingFailure"
|
||||
variant="outline"
|
||||
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
||||
>
|
||||
调度阶段
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
|
||||
{{ failureNotice.message }}
|
||||
</p>
|
||||
<div
|
||||
v-if="failureNotice.meta.length > 0"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="item in failureNotice.meta"
|
||||
:key="item"
|
||||
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用与性能概览 -->
|
||||
<Card>
|
||||
<div class="p-3 sm:p-4">
|
||||
@@ -253,8 +210,6 @@
|
||||
v-if="hasServiceTierFacts || processingTierPriceMultiplier !== null"
|
||||
class="mt-3"
|
||||
:requested="serviceTierFacts.requested"
|
||||
:actual="serviceTierFacts.actual"
|
||||
:billing="serviceTierFacts.billing"
|
||||
:price-multiplier="processingTierPriceMultiplier"
|
||||
/>
|
||||
</div>
|
||||
@@ -886,6 +841,7 @@ import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||
import type { UsageRecord } from '../types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
formatByteSize,
|
||||
@@ -906,7 +862,11 @@ import {
|
||||
resolveDisplayRequestStatus,
|
||||
resolveUsageStreamLabelSegments,
|
||||
} from '../utils/status'
|
||||
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import {
|
||||
mergeUsageRecordErrorMessage,
|
||||
parseUsageTimestampMs,
|
||||
} from '../utils/recordSync'
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
resolveProcessingTierPriceMultiplier,
|
||||
@@ -922,7 +882,11 @@ import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||
import ReplayDialog from './ReplayDialog.vue'
|
||||
import ServiceTierFacts from './ServiceTierFacts.vue'
|
||||
import { hasServiceTierFact, resolveServiceTierFacts } from '../utils/service-tier'
|
||||
import UsageModelDisplay from './UsageModelDisplay.vue'
|
||||
import {
|
||||
hasServiceTierFact,
|
||||
resolveServiceTierFacts,
|
||||
} from '../utils/service-tier'
|
||||
|
||||
// 对话解析器
|
||||
import {
|
||||
@@ -937,6 +901,7 @@ type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'ca
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
requestId: string | null
|
||||
summaryRecord?: UsageRecord | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -966,11 +931,13 @@ const emit = defineEmits<{
|
||||
endpointApiFormat?: string | null
|
||||
hasFormatConversion?: boolean | null
|
||||
targetModel?: string | null
|
||||
requestedReasoningEffort?: string | null
|
||||
reasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
actualServiceTier?: string | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
updatedAt?: string | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
@@ -1098,6 +1065,114 @@ function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'st
|
||||
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
|
||||
}
|
||||
|
||||
type HeaderModelTextField =
|
||||
| 'model'
|
||||
| 'target_model'
|
||||
| 'model_version'
|
||||
| 'requested_reasoning_effort'
|
||||
| 'reasoning_effort'
|
||||
| 'service_tier'
|
||||
| 'actual_service_tier'
|
||||
|
||||
const FINAL_PROVIDER_HEADER_FIELDS = new Set<HeaderModelTextField>([
|
||||
'target_model',
|
||||
'reasoning_effort',
|
||||
'service_tier',
|
||||
'actual_service_tier',
|
||||
])
|
||||
|
||||
let modelSnapshotRevision = 0
|
||||
const summaryModelRevision = ref(0)
|
||||
const detailModelRevision = ref(0)
|
||||
|
||||
function usageSnapshotUpdatedAtMs(
|
||||
source: UsageRecord | RequestDetail | null | undefined,
|
||||
): number | null {
|
||||
const value = source?.updated_at
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
return parseUsageTimestampMs(value)
|
||||
}
|
||||
|
||||
function summaryNullIsNewerForProviderField(
|
||||
field: HeaderModelTextField,
|
||||
nextDetail: RequestDetail | null | undefined,
|
||||
): boolean {
|
||||
if (!FINAL_PROVIDER_HEADER_FIELDS.has(field) || !props.summaryRecord) return false
|
||||
|
||||
const summaryUpdatedAt = usageSnapshotUpdatedAtMs(props.summaryRecord)
|
||||
const detailUpdatedAt = usageSnapshotUpdatedAtMs(nextDetail)
|
||||
if (summaryUpdatedAt != null && detailUpdatedAt != null && summaryUpdatedAt !== detailUpdatedAt) {
|
||||
return summaryUpdatedAt > detailUpdatedAt
|
||||
}
|
||||
|
||||
if (summaryModelRevision.value > detailModelRevision.value) return true
|
||||
|
||||
// A terminal list row is a complete final-provider snapshot. When no
|
||||
// comparable timestamps exist, its explicit null must beat a cached detail
|
||||
// from an earlier candidate. Non-terminal rows may still be filled by a
|
||||
// detail request that completed after the lightweight list response.
|
||||
return ['completed', 'failed', 'cancelled'].includes(props.summaryRecord.status ?? '')
|
||||
}
|
||||
|
||||
function readHeaderModelTextField(
|
||||
source: UsageRecord | RequestDetail | null | undefined,
|
||||
field: HeaderModelTextField,
|
||||
): { resolved: boolean, value: string | null } {
|
||||
if (!source || !Object.prototype.hasOwnProperty.call(source, field)) {
|
||||
return { resolved: false, value: null }
|
||||
}
|
||||
|
||||
const value = (source as unknown as Record<string, unknown>)[field]
|
||||
if (value === null) return { resolved: true, value: null }
|
||||
if (typeof value !== 'string') return { resolved: false, value: null }
|
||||
|
||||
const normalized = value.trim()
|
||||
return { resolved: true, value: normalized || null }
|
||||
}
|
||||
|
||||
function resolveHeaderModelTextField(
|
||||
field: HeaderModelTextField,
|
||||
nextDetail: RequestDetail | null | undefined,
|
||||
): string | null | undefined {
|
||||
// Prefer a populated list/active fact so sparse detail cannot make the header
|
||||
// flicker. A summary null is often only a lightweight-contract placeholder,
|
||||
// though, so a later populated detail is still useful. Final-provider stale
|
||||
// facts are cleared when full list/active snapshots merge into the summary.
|
||||
const summaryValue = readHeaderModelTextField(props.summaryRecord, field)
|
||||
if (summaryValue.value) return summaryValue.value
|
||||
|
||||
const detailValue = readHeaderModelTextField(nextDetail, field)
|
||||
if (detailValue.value) {
|
||||
if (
|
||||
summaryValue.resolved
|
||||
&& summaryValue.value === null
|
||||
&& summaryNullIsNewerForProviderField(field, nextDetail)
|
||||
) return null
|
||||
return detailValue.value
|
||||
}
|
||||
|
||||
return summaryValue.resolved || detailValue.resolved ? null : undefined
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.requestId,
|
||||
props.summaryRecord?.status,
|
||||
props.summaryRecord?.updated_at,
|
||||
props.summaryRecord?.model,
|
||||
props.summaryRecord?.target_model,
|
||||
props.summaryRecord?.model_version,
|
||||
props.summaryRecord?.requested_reasoning_effort,
|
||||
props.summaryRecord?.reasoning_effort,
|
||||
props.summaryRecord?.service_tier,
|
||||
props.summaryRecord?.actual_service_tier,
|
||||
],
|
||||
() => {
|
||||
summaryModelRevision.value = ++modelSnapshotRevision
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function detailTotalCost(nextDetail: RequestDetail): number | null {
|
||||
const structuredCost = typeof nextDetail.cost === 'object' ? nextDetail.cost?.total : null
|
||||
const totalCost = toNumber(nextDetail.total_cost)
|
||||
@@ -1124,6 +1199,15 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
|
||||
const id = props.requestId
|
||||
if (!id) return
|
||||
|
||||
const targetModel = resolveHeaderModelTextField('target_model', nextDetail)
|
||||
const requestedReasoningEffort = resolveHeaderModelTextField(
|
||||
'requested_reasoning_effort',
|
||||
nextDetail,
|
||||
)
|
||||
const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail)
|
||||
const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail)
|
||||
const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail)
|
||||
|
||||
emit('requestState', {
|
||||
id,
|
||||
requestId: nextDetail.request_id || nextDetail.id || null,
|
||||
@@ -1148,11 +1232,13 @@ function emitDetailRequestState(nextDetail: RequestDetail) {
|
||||
apiFormat: nextDetail.api_format ?? null,
|
||||
endpointApiFormat: nextDetail.endpoint_api_format ?? null,
|
||||
hasFormatConversion: nextDetail.has_format_conversion ?? null,
|
||||
targetModel: nextDetail.target_model ?? null,
|
||||
reasoningEffort: nextDetail.reasoning_effort ?? null,
|
||||
serviceTier: nextDetail.service_tier ?? null,
|
||||
actualServiceTier: nextDetail.actual_service_tier ?? null,
|
||||
...(targetModel ? { targetModel } : {}),
|
||||
...(requestedReasoningEffort ? { requestedReasoningEffort } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...(actualServiceTier ? { actualServiceTier } : {}),
|
||||
errorMessage: nextDetail.error_message ?? undefined,
|
||||
updatedAt: nextDetail.updated_at ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1358,10 +1444,106 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
||||
: null
|
||||
})
|
||||
|
||||
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
const detailForCurrentRequest = computed(() => (
|
||||
detailMatchesRequestId(detail.value, props.requestId) ? detail.value : null
|
||||
))
|
||||
|
||||
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
|
||||
type AuthoritativeErrorSource = 'summary' | 'detail' | null
|
||||
|
||||
function isTerminalRequestState(status: RequestStateStatus | undefined): boolean {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function isSuccessfulTerminalRequestState(status: RequestStateStatus | undefined): boolean {
|
||||
return status === 'completed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
const authoritativeErrorSource = computed<AuthoritativeErrorSource>(() => {
|
||||
const summary = props.summaryRecord
|
||||
const currentDetail = detailForCurrentRequest.value
|
||||
if (!summary || !currentDetail) return null
|
||||
|
||||
const summaryStatus = resolveRequestStateStatus(
|
||||
summary.status,
|
||||
summary.status_code,
|
||||
summary.error_message,
|
||||
)
|
||||
const detailStatus = resolveRequestStateStatusFromDetail(currentDetail)
|
||||
const summaryUpdatedAtMs = usageSnapshotUpdatedAtMs(summary)
|
||||
const detailUpdatedAtMs = usageSnapshotUpdatedAtMs(currentDetail)
|
||||
|
||||
if (summaryUpdatedAtMs != null && detailUpdatedAtMs != null &&
|
||||
summaryUpdatedAtMs !== detailUpdatedAtMs) {
|
||||
if (detailUpdatedAtMs > summaryUpdatedAtMs && isTerminalRequestState(detailStatus)) {
|
||||
return 'detail'
|
||||
}
|
||||
if (summaryUpdatedAtMs > detailUpdatedAtMs && isTerminalRequestState(summaryStatus)) {
|
||||
return 'summary'
|
||||
}
|
||||
}
|
||||
|
||||
// Without a comparable timestamp, a successful/cancelled terminal snapshot
|
||||
// still has to clear a failure from the other source. Generic failed detail
|
||||
// remains non-authoritative so opening the drawer cannot flash away a Cyber
|
||||
// refusal already resolved by the list.
|
||||
const detailSucceeded = isSuccessfulTerminalRequestState(detailStatus)
|
||||
const summarySucceeded = isSuccessfulTerminalRequestState(summaryStatus)
|
||||
if (detailSucceeded && !summarySucceeded) return 'detail'
|
||||
if (summarySucceeded && !detailSucceeded) return 'summary'
|
||||
if (detailSucceeded && summarySucceeded) {
|
||||
return detailModelRevision.value >= summaryModelRevision.value ? 'detail' : 'summary'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const headerModelRecord = computed(() => {
|
||||
const summary = props.summaryRecord
|
||||
const currentDetail = detailForCurrentRequest.value
|
||||
if (!summary && !currentDetail) return null
|
||||
|
||||
const authoritativeSource = authoritativeErrorSource.value
|
||||
const errorMessage = authoritativeSource === 'summary'
|
||||
? mergeUsageRecordErrorMessage(undefined, summary?.error_message, { authoritative: true })
|
||||
: mergeUsageRecordErrorMessage(
|
||||
summary?.error_message,
|
||||
currentDetail?.error_message,
|
||||
{ authoritative: authoritativeSource === 'detail' },
|
||||
)
|
||||
|
||||
return {
|
||||
model: resolveHeaderModelTextField('model', currentDetail) ?? '-',
|
||||
target_model: resolveHeaderModelTextField('target_model', currentDetail),
|
||||
model_version: resolveHeaderModelTextField('model_version', currentDetail),
|
||||
requested_reasoning_effort: resolveHeaderModelTextField(
|
||||
'requested_reasoning_effort',
|
||||
currentDetail,
|
||||
),
|
||||
reasoning_effort: resolveHeaderModelTextField('reasoning_effort', currentDetail),
|
||||
service_tier: resolveHeaderModelTextField('service_tier', currentDetail),
|
||||
error_message: errorMessage,
|
||||
}
|
||||
})
|
||||
const serviceTierFacts = computed(() => resolveServiceTierFacts(headerModelRecord.value))
|
||||
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
|
||||
const detailCyberPolicyError = computed(() => {
|
||||
const summaryError = props.summaryRecord?.error_message
|
||||
const currentDetail = detailForCurrentRequest.value
|
||||
const detailErrors = [
|
||||
currentDetail?.error_message,
|
||||
currentDetail?.upstream_error,
|
||||
currentDetail?.failure_summary,
|
||||
currentDetail?.response_body,
|
||||
]
|
||||
|
||||
if (authoritativeErrorSource.value === 'summary') {
|
||||
return isCyberPolicyError(summaryError)
|
||||
}
|
||||
if (authoritativeErrorSource.value === 'detail') {
|
||||
return isCyberPolicyError(detailErrors)
|
||||
}
|
||||
return isCyberPolicyError([summaryError, ...detailErrors])
|
||||
})
|
||||
const processingTierPriceMultiplier = computed(() => (
|
||||
resolveProcessingTierPriceMultiplier(detail.value)
|
||||
))
|
||||
@@ -2257,15 +2439,12 @@ const visibleTabs = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.requestId, async (newId) => {
|
||||
if (newId && props.isOpen) {
|
||||
await loadDetail(newId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
if (isOpen && props.requestId) {
|
||||
await loadDetail(props.requestId)
|
||||
watch([() => props.isOpen, () => props.requestId], async ([isOpen, requestId]) => {
|
||||
if (isOpen && requestId) {
|
||||
if (!detailMatchesRequestId(detail.value, requestId)) {
|
||||
detail.value = null
|
||||
}
|
||||
await loadDetail(requestId)
|
||||
} else if (!isOpen) {
|
||||
stopAutoRefresh()
|
||||
showTimeline.value = false
|
||||
@@ -2276,6 +2455,14 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||
}
|
||||
})
|
||||
|
||||
function detailMatchesRequestId(
|
||||
candidate: RequestDetail | null | undefined,
|
||||
requestId: string | null | undefined,
|
||||
): boolean {
|
||||
if (!candidate || !requestId) return false
|
||||
return candidate.id === requestId || candidate.request_id === requestId
|
||||
}
|
||||
|
||||
async function ensureBodyContentLoaded() {
|
||||
if (!props.requestId || !detail.value) return
|
||||
|
||||
@@ -2334,6 +2521,9 @@ async function loadDetail(id: string, silent = false) {
|
||||
const requestId = ++loadDetailRequestId
|
||||
loadDetailInFlight = true
|
||||
if (!silent) {
|
||||
if (!detailMatchesRequestId(detail.value, id)) {
|
||||
detail.value = null
|
||||
}
|
||||
loading.value = true
|
||||
historicalPricing.value = null
|
||||
timelineLoaded.value = false
|
||||
@@ -2371,6 +2561,7 @@ async function loadDetail(id: string, silent = false) {
|
||||
error_flow: response.error_flow,
|
||||
scheduling_failure: response.scheduling_failure,
|
||||
}
|
||||
detailModelRevision.value = ++modelSnapshotRevision
|
||||
detail.value = nextDetail
|
||||
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
|
||||
emitDetailRequestState(nextDetail)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<dl
|
||||
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs"
|
||||
:class="hasPriceMultiplier ? 'sm:grid-cols-4' : 'sm:grid-cols-3'"
|
||||
:class="hasPriceMultiplier ? 'sm:grid-cols-3' : 'sm:grid-cols-2'"
|
||||
data-testid="service-tier-facts"
|
||||
>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
请求层级
|
||||
上游请求层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
@@ -15,26 +15,15 @@
|
||||
{{ formatServiceTierFact(requested) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
实际层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="formatServiceTierFact(actual) || '-'"
|
||||
>
|
||||
{{ formatServiceTierFact(actual) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
<dt class="text-muted-foreground">
|
||||
计费层级
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="formatServiceTierFact(billing) || '-'"
|
||||
:title="formatServiceTierFact(requested) || '-'"
|
||||
>
|
||||
{{ formatServiceTierFact(billing) || '-' }}
|
||||
{{ formatServiceTierFact(requested) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
@@ -58,8 +47,6 @@ import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
const props = defineProps<{
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
priceMultiplier?: number | null
|
||||
}>()
|
||||
|
||||
@@ -70,7 +57,7 @@ const hasPriceMultiplier = computed(() => (
|
||||
))
|
||||
|
||||
const multiplierTierLabel = computed(() => (
|
||||
formatServiceTierFact(props.billing ?? props.actual ?? props.requested) ?? '处理层级'
|
||||
formatServiceTierFact(props.requested) ?? '处理层级'
|
||||
))
|
||||
|
||||
const formattedPriceMultiplier = computed(() => (
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-0 max-w-full flex-col gap-0.5"
|
||||
:class="shouldStackBadges && stackFullWidth ? 'w-full items-start' : 'items-start'"
|
||||
:data-usage-model-layout="shouldStackBadges ? 'stacked' : 'inline'"
|
||||
:data-request-detail-model-layout="context === 'detail'
|
||||
? (shouldStackBadges ? 'stacked' : 'inline')
|
||||
: undefined"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 max-w-full items-center gap-1"
|
||||
:class="modelRowClass"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
:class="modelClass"
|
||||
data-usage-model-source
|
||||
>{{ record.model }}</span>
|
||||
<template v-if="actualModel">
|
||||
<span class="shrink-0 text-muted-foreground/70">-></span>
|
||||
<span
|
||||
class="min-w-0 truncate"
|
||||
:class="modelClass"
|
||||
data-usage-model-target
|
||||
>{{ actualModel }}</span>
|
||||
</template>
|
||||
<template v-if="!shouldStackBadges">
|
||||
<Badge
|
||||
v-for="badge in modelBadges"
|
||||
:key="badge.key"
|
||||
:data-usage-model-badge="badge.key"
|
||||
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
|
||||
:variant="badge.variant"
|
||||
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
|
||||
:class="badge.className"
|
||||
:title="badge.title"
|
||||
:aria-label="badge.ariaLabel"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</Badge>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldStackBadges && modelBadges.length > 0"
|
||||
class="flex min-w-0 max-w-full flex-wrap items-center gap-1"
|
||||
data-usage-model-badges-row
|
||||
:data-request-detail-model-badges-row="context === 'detail' ? '' : undefined"
|
||||
>
|
||||
<Badge
|
||||
v-for="badge in modelBadges"
|
||||
:key="badge.key"
|
||||
:data-usage-model-badge="badge.key"
|
||||
:data-request-detail-model-badge="context === 'detail' ? badge.key : undefined"
|
||||
:variant="badge.variant"
|
||||
class="h-4 shrink-0 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4"
|
||||
:class="badge.className"
|
||||
:title="badge.title"
|
||||
:aria-label="badge.ariaLabel"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
type ModelBadgeKey = 'compact' | 'reasoning' | 'fast' | 'cyber'
|
||||
|
||||
interface ModelBadgePresentation {
|
||||
key: ModelBadgeKey
|
||||
label: string
|
||||
variant: 'outline' | 'outline-transparent'
|
||||
className: string
|
||||
title: string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
interface UsageModelDisplayRecord {
|
||||
model: string
|
||||
target_model?: string | null
|
||||
model_version?: string | null
|
||||
request_type?: string | null
|
||||
requested_reasoning_effort?: string | null
|
||||
reasoning_effort?: string | null
|
||||
service_tier?: string | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
record: UsageModelDisplayRecord
|
||||
modelClass?: string
|
||||
modelRowClass?: string
|
||||
context?: 'usage' | 'detail'
|
||||
cyber?: boolean | null
|
||||
stackFullWidth?: boolean
|
||||
}>(), {
|
||||
modelClass: '',
|
||||
modelRowClass: '',
|
||||
context: 'usage',
|
||||
cyber: null,
|
||||
stackFullWidth: false,
|
||||
})
|
||||
|
||||
const actualModel = computed(() => {
|
||||
const targetModel = normalizeText(props.record.target_model)
|
||||
if (targetModel && targetModel !== props.record.model) return targetModel
|
||||
|
||||
const modelVersion = normalizeText(props.record.model_version)
|
||||
if (modelVersion && modelVersion !== props.record.model) return modelVersion
|
||||
return null
|
||||
})
|
||||
|
||||
const reasoningLabel = computed(() => {
|
||||
const requested = normalizeText(props.record.requested_reasoning_effort)
|
||||
const actual = normalizeText(props.record.reasoning_effort)
|
||||
if (requested && actual && requested.toLowerCase() !== actual.toLowerCase()) {
|
||||
return `${requested} -> ${actual}`
|
||||
}
|
||||
return actual ?? requested
|
||||
})
|
||||
|
||||
const modelBadges = computed<ModelBadgePresentation[]>(() => {
|
||||
const badges: ModelBadgePresentation[] = []
|
||||
if (normalizeText(props.record.request_type)?.toLowerCase() === 'compact') {
|
||||
badges.push({
|
||||
key: 'compact',
|
||||
label: '会话压缩',
|
||||
variant: 'outline',
|
||||
className: 'border-sky-500/30 bg-sky-500/5 text-sky-700 dark:text-sky-300',
|
||||
title: '会话压缩',
|
||||
ariaLabel: '会话压缩',
|
||||
})
|
||||
}
|
||||
if (reasoningLabel.value) {
|
||||
badges.push({
|
||||
key: 'reasoning',
|
||||
label: reasoningLabel.value,
|
||||
variant: 'outline',
|
||||
className: 'border-primary/30 bg-primary/5 text-primary',
|
||||
title: `Reasoning: ${reasoningLabel.value}`,
|
||||
ariaLabel: `Reasoning: ${reasoningLabel.value}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (formatServiceTierFact(props.record.service_tier) === 'Fast') {
|
||||
badges.push({
|
||||
key: 'fast',
|
||||
label: 'Fast',
|
||||
variant: 'outline-transparent',
|
||||
className: 'text-amber-700 dark:text-amber-300',
|
||||
title: '上游请求档位:Fast\n计费档位:Fast',
|
||||
ariaLabel: '上游请求档位:Fast,计费档位:Fast',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.cyber ?? isCyberPolicyError(props.record.error_message)) {
|
||||
badges.push({
|
||||
key: 'cyber',
|
||||
label: 'Cyber',
|
||||
variant: 'outline',
|
||||
className: 'border-primary/30 bg-primary/5 text-rose-600 dark:text-rose-300',
|
||||
title: '上游 Cyber Policy 拒绝',
|
||||
ariaLabel: '上游 Cyber Policy 拒绝',
|
||||
})
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
const shouldStackBadges = computed(() => (
|
||||
actualModel.value !== null || modelBadges.value.length >= 3
|
||||
))
|
||||
|
||||
function normalizeText(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim()
|
||||
return normalized || null
|
||||
}
|
||||
</script>
|
||||
@@ -242,34 +242,12 @@
|
||||
<!-- 第一行:模型 + 费用 -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="min-w-0 truncate text-[15px] font-semibold leading-5">{{ record.model }}</span>
|
||||
<Badge
|
||||
v-if="getRequestTypeLabel(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
|
||||
title="会话压缩"
|
||||
>
|
||||
{{ getRequestTypeLabel(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getReasoningEffort(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
|
||||
:title="getReasoningEffortTitle(record)"
|
||||
>
|
||||
{{ getReasoningEffort(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getServiceTierBadge(record)"
|
||||
variant="outline"
|
||||
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
|
||||
:class="getServiceTierBadge(record)?.className"
|
||||
:title="getServiceTierBadge(record)?.title"
|
||||
:aria-label="getServiceTierBadge(record)?.ariaLabel"
|
||||
>
|
||||
{{ getServiceTierBadge(record)?.label }}
|
||||
</Badge>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<UsageModelDisplay
|
||||
:record="record"
|
||||
model-class="text-[15px] font-semibold leading-5"
|
||||
stack-full-width
|
||||
/>
|
||||
<!-- 状态 Badge -->
|
||||
<Badge
|
||||
v-if="isUsageRecordFailed(record)"
|
||||
@@ -320,10 +298,6 @@
|
||||
{{ getStreamModeLabel(record) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<span
|
||||
v-if="getActualModel(record)"
|
||||
class="text-[11px] text-muted-foreground truncate block"
|
||||
>-> {{ getActualModel(record) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end flex-shrink-0">
|
||||
<span class="text-sm text-primary font-semibold leading-5">{{ formatCurrency(record.cost || 0) }}</span>
|
||||
@@ -745,85 +719,10 @@
|
||||
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||
:title="getModelTooltip(record)"
|
||||
>
|
||||
<div
|
||||
v-if="getActualModel(record)"
|
||||
class="flex flex-col text-xs gap-0.5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<span class="truncate">{{ record.model }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 text-muted-foreground flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<Badge
|
||||
v-if="getRequestTypeLabel(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
|
||||
title="会话压缩"
|
||||
>
|
||||
{{ getRequestTypeLabel(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getReasoningEffort(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
|
||||
:title="getReasoningEffortTitle(record)"
|
||||
>
|
||||
{{ getReasoningEffort(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getServiceTierBadge(record)"
|
||||
variant="outline"
|
||||
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
|
||||
:class="getServiceTierBadge(record)?.className"
|
||||
:title="getServiceTierBadge(record)?.title"
|
||||
:aria-label="getServiceTierBadge(record)?.ariaLabel"
|
||||
>
|
||||
{{ getServiceTierBadge(record)?.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="flex min-w-0 items-center gap-1"
|
||||
>
|
||||
<span class="truncate">{{ record.model }}</span>
|
||||
<Badge
|
||||
v-if="getRequestTypeLabel(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
|
||||
title="会话压缩"
|
||||
>
|
||||
{{ getRequestTypeLabel(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getReasoningEffort(record)"
|
||||
variant="outline"
|
||||
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
|
||||
:title="getReasoningEffortTitle(record)"
|
||||
>
|
||||
{{ getReasoningEffort(record) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="getServiceTierBadge(record)"
|
||||
variant="outline"
|
||||
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
|
||||
:class="getServiceTierBadge(record)?.className"
|
||||
:title="getServiceTierBadge(record)?.title"
|
||||
:aria-label="getServiceTierBadge(record)?.ariaLabel"
|
||||
>
|
||||
{{ getServiceTierBadge(record)?.label }}
|
||||
</Badge>
|
||||
</span>
|
||||
<UsageModelDisplay
|
||||
:record="record"
|
||||
class="text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="isAdmin && isColumnVisible('provider')"
|
||||
@@ -1130,11 +1029,13 @@ import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
import { isCyberPolicyError } from '../utils/cyberError'
|
||||
import type { DateRangeParams, UsageRecord } from '../types'
|
||||
import { MultiSelect, TimeRangePicker } from '@/components/common'
|
||||
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
|
||||
import ElapsedTimeText from './ElapsedTimeText.vue'
|
||||
import ServerUserSelector from './ServerUserSelector.vue'
|
||||
import UsageModelDisplay from './UsageModelDisplay.vue'
|
||||
|
||||
export interface UserOption {
|
||||
id: string
|
||||
@@ -1632,24 +1533,20 @@ function getActualModel(record: UsageRecord): string | null {
|
||||
}
|
||||
|
||||
function getReasoningEffort(record: UsageRecord): string | null {
|
||||
const effort = record.reasoning_effort?.trim()
|
||||
return effort || null
|
||||
const requested = record.requested_reasoning_effort?.trim()
|
||||
const actual = record.reasoning_effort?.trim()
|
||||
if (requested && actual && requested.toLowerCase() !== actual.toLowerCase()) {
|
||||
return `${requested} -> ${actual}`
|
||||
}
|
||||
return actual || requested || null
|
||||
}
|
||||
|
||||
function getRequestTypeLabel(record: UsageRecord): string | null {
|
||||
return record.request_type?.trim().toLowerCase() === 'compact' ? '会话压缩' : null
|
||||
function hasCyberPolicyError(record: UsageRecord): boolean {
|
||||
return isCyberPolicyError(record.error_message)
|
||||
}
|
||||
|
||||
function getReasoningEffortTitle(record: UsageRecord): string {
|
||||
const effort = getReasoningEffort(record)
|
||||
return effort ? `Reasoning: ${effort}` : ''
|
||||
}
|
||||
|
||||
type ServiceTierBadgeState = 'confirmed' | 'downgraded' | 'upgraded' | 'pending' | 'unconfirmed'
|
||||
|
||||
interface ServiceTierBadgePresentation {
|
||||
label: string
|
||||
state: ServiceTierBadgeState
|
||||
className: string
|
||||
title: string
|
||||
ariaLabel: string
|
||||
@@ -1670,44 +1567,19 @@ function canonicalServiceTier(value: string | null): string | null {
|
||||
return value
|
||||
}
|
||||
|
||||
function serviceTierBadgeClass(state: ServiceTierBadgeState): string {
|
||||
switch (state) {
|
||||
case 'confirmed':
|
||||
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
case 'downgraded':
|
||||
return 'border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
case 'upgraded':
|
||||
return 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300'
|
||||
case 'pending':
|
||||
return 'border-dashed border-muted-foreground/30 bg-muted/30 text-muted-foreground'
|
||||
case 'unconfirmed':
|
||||
return 'border-dashed border-amber-500/40 bg-amber-500/5 text-amber-700 dark:text-amber-300'
|
||||
}
|
||||
}
|
||||
|
||||
function buildServiceTierBadgePresentation(
|
||||
label: string,
|
||||
state: ServiceTierBadgeState,
|
||||
requestedRaw: string | null,
|
||||
actualRaw: string | null,
|
||||
billingTier: string | null,
|
||||
): ServiceTierBadgePresentation {
|
||||
const titleLines: string[] = []
|
||||
const requestedLabel = formatServiceTierFact(requestedRaw)
|
||||
const actualLabel = formatServiceTierFact(actualRaw)
|
||||
const billingLabel = formatServiceTierFact(billingTier)
|
||||
if (requestedLabel) titleLines.push(`请求档位:${requestedLabel}`)
|
||||
if (actualLabel) titleLines.push(`实际档位:${actualLabel}`)
|
||||
if (billingLabel) {
|
||||
titleLines.push(`计费档位:${billingLabel}`)
|
||||
} else {
|
||||
titleLines.push(`计费档位:${state === 'pending' ? '待上游确认' : '未确认'}`)
|
||||
}
|
||||
if (requestedLabel) titleLines.push(`上游请求档位:${requestedLabel}`)
|
||||
// Billing is resolved from the same final provider request tier. Keep it
|
||||
// explicit in the tooltip without consulting a response-side tier.
|
||||
if (requestedLabel) titleLines.push(`计费档位:${requestedLabel}`)
|
||||
const title = titleLines.join('\n')
|
||||
return {
|
||||
label,
|
||||
state,
|
||||
className: serviceTierBadgeClass(state),
|
||||
label: 'Fast',
|
||||
className: '!bg-transparent text-amber-700 dark:text-amber-300',
|
||||
title,
|
||||
ariaLabel: titleLines.join(','),
|
||||
}
|
||||
@@ -1715,54 +1587,10 @@ function buildServiceTierBadgePresentation(
|
||||
|
||||
function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation | null {
|
||||
const requestedRaw = normalizeServiceTier(record.service_tier)
|
||||
const actualRaw = normalizeServiceTier(record.actual_service_tier)
|
||||
const requested = canonicalServiceTier(requestedRaw)
|
||||
const actual = canonicalServiceTier(actualRaw)
|
||||
const requestedFast = requested === 'priority'
|
||||
const actualFast = actual === 'priority'
|
||||
|
||||
if (actual) {
|
||||
if (requestedFast && !actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
`Fast → ${actual}`,
|
||||
'downgraded',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
if (!requestedFast && actualFast) {
|
||||
const requestedLabel = requested ?? 'standard'
|
||||
return buildServiceTierBadgePresentation(
|
||||
requested ? `${requestedLabel} → Fast` : 'Fast',
|
||||
requested ? 'upgraded' : 'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
if (actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
'Fast',
|
||||
'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!requestedFast) return null
|
||||
const displayStatus = getDisplayStatus(record)
|
||||
const isActive = displayStatus === 'pending' || displayStatus === 'streaming'
|
||||
return buildServiceTierBadgePresentation(
|
||||
isActive ? 'Fast · 待确认' : 'Fast · 未确认',
|
||||
isActive ? 'pending' : 'unconfirmed',
|
||||
requestedRaw,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
return buildServiceTierBadgePresentation(requestedRaw)
|
||||
}
|
||||
|
||||
function getServiceTierTitle(record: UsageRecord): string {
|
||||
@@ -1770,10 +1598,9 @@ function getServiceTierTitle(record: UsageRecord): string {
|
||||
if (badge) return badge.title
|
||||
|
||||
const requested = formatServiceTierFact(record.service_tier)
|
||||
const actual = formatServiceTierFact(record.actual_service_tier)
|
||||
return [
|
||||
requested ? `请求档位:${requested}` : null,
|
||||
actual ? `实际档位:${actual}` : null,
|
||||
requested ? `上游请求档位:${requested}` : null,
|
||||
requested ? `计费档位:${requested}` : null,
|
||||
].filter((line): line is string => Boolean(line)).join('\n')
|
||||
}
|
||||
|
||||
@@ -1781,10 +1608,10 @@ function getServiceTierTitle(record: UsageRecord): string {
|
||||
function getModelTooltip(record: UsageRecord): string {
|
||||
const actualModel = getActualModel(record)
|
||||
const reasoningEffort = getReasoningEffort(record)
|
||||
const requestType = getRequestTypeLabel(record)
|
||||
const serviceTierTitle = getServiceTierTitle(record)
|
||||
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
|
||||
const suffix = `${requestType ? `\n操作: ${requestType}` : ''}${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}`
|
||||
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
|
||||
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
|
||||
if (actualModel) {
|
||||
return `${record.model} -> ${actualModel}${suffix}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, type App } from 'vue'
|
||||
import { createApp, h, nextTick, reactive, type App } from 'vue'
|
||||
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
@@ -15,6 +15,18 @@ function mountElapsedTimeText(props: Record<string, unknown>) {
|
||||
return root
|
||||
}
|
||||
|
||||
function mountReactiveElapsedTimeText(initialProps: Record<string, unknown>) {
|
||||
const props = reactive(initialProps)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ElapsedTimeText, { ...props }),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return { props, root }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
@@ -53,4 +65,30 @@ describe('ElapsedTimeText', () => {
|
||||
|
||||
expect(root.textContent).toBe('4.00s')
|
||||
})
|
||||
|
||||
it('does not pause or move total time backwards when the first-byte clock arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-17T12:00:06.250Z'))
|
||||
|
||||
const { props, root } = mountReactiveElapsedTimeText({
|
||||
status: 'pending',
|
||||
createdAt: '2026-07-17T12:00:00Z',
|
||||
responseTimeUpdatedAt: null,
|
||||
responseTimeMs: null,
|
||||
})
|
||||
await nextTick()
|
||||
expect(root.textContent).toBe('6.25s')
|
||||
|
||||
// The first-byte snapshot implies 5.85s at the same instant because its
|
||||
// timestamp is truncated to seconds. The visible clock must stay continuous.
|
||||
props.status = 'streaming'
|
||||
props.responseTimeUpdatedAt = '2026-07-17T12:00:06Z'
|
||||
props.responseTimeMs = 5600
|
||||
await nextTick()
|
||||
expect(root.textContent).toBe('6.25s')
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(Number.parseFloat(root.textContent ?? '')).toBeGreaterThanOrEqual(6.74)
|
||||
})
|
||||
})
|
||||
|
||||
+60
-29
@@ -58,9 +58,13 @@ vi.mock('../JsonContentPanel.vue', async () => {
|
||||
type: null,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'JSON',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('pre', JSON.stringify(props.data))
|
||||
return () => h('pre', { 'data-title': props.title }, JSON.stringify(props.data))
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -517,7 +521,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(requestPathCode?.textContent).toContain('/v1/images/generations')
|
||||
})
|
||||
|
||||
it('shows upstream response JSON inside the error block on trace nodes', async () => {
|
||||
it('shows upstream response headers and body in one error envelope', async () => {
|
||||
const upstreamErrorMessage = 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-upstream-response',
|
||||
@@ -527,21 +532,33 @@ describe('HorizontalRequestTimeline', () => {
|
||||
key_name: 'Upstream Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
error_message: 'execution runtime stream returned non-success status 302',
|
||||
error_message: 'execution runtime stream returned non-success status 400',
|
||||
extra_data: {
|
||||
upstream_response: {
|
||||
status_code: 302,
|
||||
headers: { location: '/' },
|
||||
status_code: 400,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'req_usage-cyber-risk-demo',
|
||||
},
|
||||
body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: upstreamErrorMessage,
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
body_ref: 'usage://request/req-1/response_body',
|
||||
body_state: 'reference',
|
||||
},
|
||||
error_flow: {
|
||||
source: 'upstream_response',
|
||||
status_code: 302,
|
||||
status_code: 400,
|
||||
classification: 'use_default',
|
||||
decision: 'use_default',
|
||||
propagation: 'none',
|
||||
retryable: false,
|
||||
safe_to_expose: false,
|
||||
message: 'execution runtime stream returned non-success status 302',
|
||||
message: 'execution runtime stream returned non-success status 400',
|
||||
},
|
||||
client_response: {
|
||||
status_code: 502,
|
||||
@@ -555,15 +572,31 @@ describe('HorizontalRequestTimeline', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('HTTP 302')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"')
|
||||
expect(root.textContent).toContain('HTTP 400')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 400')
|
||||
const upstreamResponse = root.querySelector<HTMLElement>('.error-upstream-response-json pre')
|
||||
expect(upstreamResponse?.dataset.title).toBe('上游响应')
|
||||
expect(JSON.parse(upstreamResponse?.textContent ?? '{}')).toEqual({
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': 'req_usage-cyber-risk-demo',
|
||||
},
|
||||
body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: upstreamErrorMessage,
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(upstreamResponse?.textContent).not.toContain('"status_code"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"headers"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"body_ref"')
|
||||
expect(upstreamResponse?.textContent).not.toContain('"body_state"')
|
||||
expect(root.textContent).not.toContain('上游真实响应')
|
||||
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 302')
|
||||
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 400')
|
||||
expect(root.textContent).not.toContain('真实请求错误')
|
||||
expect(root.textContent).not.toContain('返回客户端响应')
|
||||
expect(root.textContent).not.toContain('上游响应')
|
||||
expect(root.textContent).not.toContain('默认处理')
|
||||
expect(root.textContent).not.toContain('none')
|
||||
expect(root.textContent).not.toContain('不再重试')
|
||||
@@ -601,12 +634,12 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('上游返回了当前不支持的 stream event')
|
||||
expect(root.textContent).toContain('字段 $.type = "response.future.delta"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.type"')
|
||||
expect(errorJsonText).toContain('"analysis_hint"')
|
||||
expect(errorJsonText).toContain('"raw"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.type"')
|
||||
expect(diagnosticText).toContain('"analysis_hint"')
|
||||
expect(diagnosticText).toContain('"raw"')
|
||||
expect(diagnosticText).toContain('"body_state":"disabled"')
|
||||
expect(root.querySelector('.error-upstream-response-json')?.textContent).toContain('"header":{"content-type":"application/json"}')
|
||||
})
|
||||
|
||||
it('formats request conversion diagnostics with field paths on skipped trace nodes', async () => {
|
||||
@@ -664,9 +697,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('finish reason')
|
||||
expect(root.textContent).toContain('字段 $.finish_reason = "future_reason"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.finish_reason"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.finish_reason"')
|
||||
})
|
||||
|
||||
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
|
||||
@@ -701,10 +733,10 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
|
||||
expect(root.textContent).toContain('字段 $.n 会丢失信息')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 500')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.n"')
|
||||
expect(errorJsonText).toContain('断点在请求/响应格式转换器')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"body_state":"disabled"')
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.n"')
|
||||
expect(diagnosticText).toContain('断点在请求/响应格式转换器')
|
||||
})
|
||||
|
||||
it('shows failed diagnostic messages even when the only response panel data is diagnostic metadata', async () => {
|
||||
@@ -735,9 +767,8 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('格式转换失败')
|
||||
expect(root.textContent).toContain('OpenAI Responses 不支持字段 $.temperature')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.temperature"')
|
||||
const diagnosticText = root.querySelector('.error-diagnostic-json')?.textContent ?? ''
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.temperature"')
|
||||
})
|
||||
|
||||
it('keeps the failure message when upstream response only records an empty body state', async () => {
|
||||
|
||||
@@ -97,4 +97,388 @@ describe('RequestDetailDrawer settlement pricing', () => {
|
||||
})
|
||||
expect(document.body.textContent).not.toContain('输出 $0/M')
|
||||
})
|
||||
|
||||
it('shows mapping, reasoning, Fast, and Cyber together in the model header', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-risk-demo',
|
||||
request_id: 'req_usage-cyber-risk-demo',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
request_body: {
|
||||
model: 'gpt-5',
|
||||
reasoning: { effort: 'xhigh' },
|
||||
},
|
||||
service_tier: 'priority',
|
||||
// A response-side tier must not be used for the Fast badge or billing.
|
||||
actual_service_tier: 'default',
|
||||
provider_request_body: {
|
||||
model: 'gpt-5.1',
|
||||
reasoning: { effort: 'max' },
|
||||
service_tier: 'priority',
|
||||
},
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
response_body: {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'This content was flagged for possible cybersecurity risk.',
|
||||
code: 400,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-cyber-risk-demo',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
|
||||
.toContain('gpt-5')
|
||||
expect(document.body.querySelector('[data-request-detail-model-display]')?.textContent)
|
||||
.toContain('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent?.trim())
|
||||
.toBe('Fast')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
const modelLayout = document.body.querySelector(
|
||||
'[data-request-detail-model-layout="stacked"]',
|
||||
)
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('->')
|
||||
expect(modelLayout?.firstElementChild?.textContent).toContain('gpt-5.1')
|
||||
expect(modelLayout?.firstElementChild?.querySelector('[data-request-detail-model-badge]'))
|
||||
.toBeNull()
|
||||
const modelBadgesRow = modelLayout?.querySelector(
|
||||
'[data-request-detail-model-badges-row]',
|
||||
)
|
||||
expect(modelBadgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(modelBadgesRow?.textContent).toContain('Fast')
|
||||
expect(modelBadgesRow?.textContent).toContain('Cyber')
|
||||
const serviceTierFacts = document.body.querySelector('[data-testid="service-tier-facts"]')
|
||||
expect([...serviceTierFacts?.querySelectorAll('dt') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['上游请求层级', '计费层级'])
|
||||
expect([...serviceTierFacts?.querySelectorAll('dd') ?? []].map(node => node.textContent?.trim()))
|
||||
.toEqual(['Fast', 'Fast'])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the Cyber badge stable from the selected row while lightweight detail loads', async () => {
|
||||
let resolveDetail!: (value: RequestDetail) => void
|
||||
apiMocks.getRequestDetail.mockReturnValue(new Promise<RequestDetail>((resolve) => {
|
||||
resolveDetail = resolve
|
||||
}))
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const requestId = ref('usage-cyber-summary')
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-cyber-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: requestId.value,
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
|
||||
resolveDetail({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-summary',
|
||||
request_id: 'usage-cyber-summary',
|
||||
model: 'gpt-5',
|
||||
// Lightweight detail can legitimately omit these final-provider facts.
|
||||
target_model: null,
|
||||
requested_reasoning_effort: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'execution runtime stream returned non-success status 400',
|
||||
response_body: null,
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent)
|
||||
.toContain('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent)
|
||||
.toContain('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')?.textContent)
|
||||
.toContain('Fast')
|
||||
const tierValues = [
|
||||
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
|
||||
].map(node => node.textContent?.trim())
|
||||
expect(tierValues).toEqual(['Fast', 'Fast'])
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale summary Cyber badge when newer detail completed successfully', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-cyber-recovered',
|
||||
request_id: 'usage-cyber-recovered',
|
||||
model: 'gpt-5',
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-cyber-recovered',
|
||||
summaryRecord: {
|
||||
id: 'usage-cyber-recovered',
|
||||
model: 'gpt-5',
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cost: 0,
|
||||
is_stream: false,
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
created_at: '2026-07-17T00:00:00Z',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="cyber"]'))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('uses populated detail fallbacks without overriding a populated summary tier', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-standard-summary',
|
||||
request_id: 'usage-standard-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
// The non-empty summary tier remains authoritative over this stale fact.
|
||||
service_tier: 'priority',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-standard-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: null,
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: null,
|
||||
service_tier: 'default',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-standard-summary',
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh -> max')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
|
||||
const tierValues = [
|
||||
...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd'),
|
||||
].map(node => node.textContent?.trim())
|
||||
expect(tierValues).toEqual(['default', 'default'])
|
||||
})
|
||||
})
|
||||
|
||||
it('uses detail model_version when the lightweight summary has null model facts', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-version-summary',
|
||||
request_id: 'usage-version-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: 'gpt-5.1-2026-07-17',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-version-summary',
|
||||
summaryRecord: {
|
||||
id: 'usage-version-summary',
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
model_version: null,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cost: 0,
|
||||
is_stream: false,
|
||||
created_at: '2026-07-17T00:00:00Z',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-usage-model-target]')?.textContent?.trim())
|
||||
.toBe('gpt-5.1-2026-07-17')
|
||||
})
|
||||
})
|
||||
|
||||
it('lets a newer final-provider summary clear facts cached from an earlier candidate', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue({
|
||||
...buildEmbeddingDetail(),
|
||||
id: 'usage-final-candidate',
|
||||
request_id: 'usage-final-candidate',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
status: 'streaming',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const summaryRecord = ref<Record<string, unknown>>({
|
||||
id: 'usage-final-candidate',
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
status: 'streaming',
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-final-candidate',
|
||||
summaryRecord: summaryRecord.value as never,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
summaryRecord.value = {
|
||||
...summaryRecord.value,
|
||||
target_model: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.querySelector('[data-usage-model-target]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh')
|
||||
expect(document.body.querySelector('[data-request-detail-model-badge="fast"]')).toBeNull()
|
||||
expect([...document.body.querySelectorAll('[data-testid="service-tier-facts"] dd')]
|
||||
.some(node => node.textContent?.trim() === 'Fast')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,14 +13,12 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ServiceTierFacts', () => {
|
||||
it('renders all three facts and marks a missing actual tier explicitly', () => {
|
||||
it('renders request and billing facts from the same request tier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: 'flex',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
@@ -28,30 +26,25 @@ describe('ServiceTierFacts', () => {
|
||||
|
||||
expect(root.querySelector('[data-testid="service-tier-facts"]')).not.toBeNull()
|
||||
expect([...root.querySelectorAll('dt')].map(node => node.textContent?.trim())).toEqual([
|
||||
'请求层级',
|
||||
'实际层级',
|
||||
'上游请求层级',
|
||||
'计费层级',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
'Fast',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
'Fast',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the same Fast label for raw priority and fast facts', () => {
|
||||
it('uses the Fast label for a raw fast request tier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'priority',
|
||||
requested: 'fast',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
@@ -60,12 +53,10 @@ describe('ServiceTierFacts', () => {
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -75,8 +66,6 @@ describe('ServiceTierFacts', () => {
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'fast',
|
||||
priceMultiplier: 2.5,
|
||||
}),
|
||||
})
|
||||
@@ -94,8 +83,6 @@ describe('ServiceTierFacts', () => {
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: null,
|
||||
priceMultiplier: null,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -290,19 +290,31 @@ describe('UsageRecordsTable', () => {
|
||||
})
|
||||
|
||||
it('shows reasoning effort next to the model name', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({ reasoning_effort: 'xhigh' })])
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('gpt-5')
|
||||
expect(root.textContent).toContain('xhigh')
|
||||
const inlineLayout = root.querySelector('[data-usage-model-layout="inline"]')
|
||||
expect(inlineLayout).not.toBeNull()
|
||||
expect(inlineLayout?.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('xhigh')
|
||||
expect(inlineLayout?.querySelector('[data-usage-model-badge="fast"]')?.textContent?.trim())
|
||||
.toBe('Fast')
|
||||
})
|
||||
|
||||
it('shows request reasoning effort while the record is pending', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
reasoning_effort: 'max',
|
||||
requested_reasoning_effort: 'max',
|
||||
reasoning_effort: null,
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('max')
|
||||
expect(root.querySelector('[data-usage-model-badge="reasoning"]')?.textContent?.trim())
|
||||
.toBe('max')
|
||||
})
|
||||
|
||||
it('marks Responses compaction while the record is pending', () => {
|
||||
@@ -311,65 +323,145 @@ describe('UsageRecordsTable', () => {
|
||||
request_type: 'compact',
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('会话压缩')
|
||||
expect(root.querySelector('[data-usage-model-badge="compact"]')?.textContent?.trim())
|
||||
.toBe('会话压缩')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['priority', 'priority'],
|
||||
['fast', 'fast'],
|
||||
['priority', 'fast'],
|
||||
['fast', 'priority'],
|
||||
])('shows confirmed Fast for requested %s and actual %s', (requested, actual) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: requested,
|
||||
actual_service_tier: actual,
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:Fast',
|
||||
'计费档位:Fast',
|
||||
].join('\n'))
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'请求档位:Fast,实际档位:Fast,计费档位:Fast',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows fast to standard when the provider downgrades a priority request', () => {
|
||||
it('shows mapping, reasoning, Fast, and Cyber together in the model area', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
// A conflicting response-side value must not affect the Fast badge.
|
||||
actual_service_tier: 'default',
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast → standard')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:default',
|
||||
'计费档位:standard',
|
||||
].join('\n'))
|
||||
expect(root.textContent).toContain('gpt-5')
|
||||
expect(root.textContent).toContain('gpt-5.1')
|
||||
expect(root.textContent).toContain('xhigh -> max')
|
||||
expect(root.textContent).toContain('Fast')
|
||||
const reasoningBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="reasoning"]')
|
||||
const fastBadge = root.querySelector<HTMLElement>('[data-usage-model-badge="fast"]')
|
||||
const cyberBadges = root.querySelectorAll<HTMLElement>('[data-usage-model-badge="cyber"]')
|
||||
const cyberBadge = cyberBadges[0]
|
||||
for (const badge of [reasoningBadge, fastBadge, cyberBadge]) {
|
||||
expect(badge).not.toBeNull()
|
||||
expect(badge?.classList.contains('h-4')).toBe(true)
|
||||
expect(badge?.classList.contains('rounded-full')).toBe(true)
|
||||
expect(badge?.classList.contains('px-1.5')).toBe(true)
|
||||
expect(badge?.classList.contains('text-[10px]')).toBe(true)
|
||||
expect(badge?.classList.contains('leading-4')).toBe(true)
|
||||
}
|
||||
expect(reasoningBadge?.classList.contains('border-primary/30')).toBe(true)
|
||||
expect(reasoningBadge?.classList.contains('bg-primary/5')).toBe(true)
|
||||
expect(reasoningBadge?.classList.contains('text-primary')).toBe(true)
|
||||
expect(fastBadge?.getAttribute('variant')).toBe('outline-transparent')
|
||||
expect(fastBadge?.classList.contains('border-amber-400/50')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('!bg-transparent')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('bg-amber-400/10')).toBe(false)
|
||||
expect(fastBadge?.classList.contains('text-amber-700')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('border-primary/30')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('bg-primary/5')).toBe(true)
|
||||
expect(cyberBadge?.classList.contains('text-rose-600')).toBe(true)
|
||||
expect(cyberBadges.length).toBeGreaterThan(0)
|
||||
expect([...cyberBadges].every(badge => badge.textContent?.trim() === 'Cyber')).toBe(true)
|
||||
expect([...cyberBadges].every(badge => badge.title === '上游 Cyber Policy 拒绝')).toBe(true)
|
||||
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout).not.toBeNull()
|
||||
const modelRow = stackedLayout?.firstElementChild
|
||||
expect(modelRow?.textContent).toContain('gpt-5')
|
||||
expect(modelRow?.textContent).toContain('->')
|
||||
expect(modelRow?.textContent).toContain('gpt-5.1')
|
||||
expect(modelRow?.querySelector('[data-usage-model-badge]')).toBeNull()
|
||||
const badgesRow = stackedLayout?.querySelector('[data-usage-model-badges-row]')
|
||||
expect(badgesRow?.textContent).toContain('xhigh -> max')
|
||||
expect(badgesRow?.textContent).toContain('Fast')
|
||||
expect(badgesRow?.textContent).toContain('Cyber')
|
||||
})
|
||||
|
||||
it('shows fast to flex when the provider moves a priority request to flex', () => {
|
||||
it('stacks three model badges even without a model mapping', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
model: 'gpt-5',
|
||||
target_model: null,
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'flex',
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast → flex')
|
||||
const stackedLayout = root.querySelector('[data-usage-model-layout="stacked"]')
|
||||
expect(stackedLayout?.firstElementChild?.textContent?.trim()).toBe('gpt-5')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('xhigh')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Fast')
|
||||
expect(stackedLayout?.querySelector('[data-usage-model-badges-row]')?.textContent)
|
||||
.toContain('Cyber')
|
||||
})
|
||||
|
||||
it('shows standard to fast when the provider upgrades a default request', () => {
|
||||
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
|
||||
'shows Fast from the final provider request tier %s',
|
||||
(requested) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: requested,
|
||||
actual_service_tier: 'default',
|
||||
})])
|
||||
|
||||
const badge = expectServiceTierBadge(root, 'Fast')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'上游请求档位:Fast',
|
||||
'计费档位:Fast',
|
||||
].join('\n'))
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'上游请求档位:Fast,计费档位:Fast',
|
||||
)
|
||||
expect(badge.textContent).not.toContain('→')
|
||||
expect(badge.textContent).not.toContain('待确认')
|
||||
expect(badge.textContent).not.toContain('未确认')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['default', 'flex', null])(
|
||||
'ignores the response-side tier %s when the request tier is Fast',
|
||||
(actualServiceTier) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: actualServiceTier,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
expect(root.textContent).not.toContain('Fast →')
|
||||
},
|
||||
)
|
||||
|
||||
it('does not infer Fast from a response-side priority tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'default',
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'standard → Fast')
|
||||
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not infer Fast when only the response has a tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: null,
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expect(root.querySelector('[data-usage-model-badge="fast"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['pending', 'streaming'] as const)(
|
||||
'shows fast as pending confirmation while a priority request is %s',
|
||||
'keeps Fast stable while a priority request is %s',
|
||||
(status) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
@@ -377,18 +469,18 @@ describe('UsageRecordsTable', () => {
|
||||
status,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast · 待确认')
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
},
|
||||
)
|
||||
|
||||
it('shows fast as unconfirmed when a completed priority request has no actual tier', () => {
|
||||
it('keeps Fast stable for a completed request without a response tier', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: null,
|
||||
status: 'completed',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'Fast · 未确认')
|
||||
expectServiceTierBadge(root, 'Fast')
|
||||
})
|
||||
|
||||
it('offers embedding API formats in the usage record filter', () => {
|
||||
|
||||
@@ -162,7 +162,146 @@ describe('useUsageData', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves detail-filled usage metrics when a later list refresh is still empty', async () => {
|
||||
it('clears a failed candidate Cyber snapshot when the final candidate completes', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: cyberMessage,
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})
|
||||
expect(currentRecords.value[0]?.error_message).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an older same-rank terminal snapshot as a unit', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: cyberMessage,
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
})
|
||||
expect(currentRecords.value[0]?.error_message).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps live response duration and its anchor atomic across stale refreshes', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'streaming',
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
first_byte_time_ms: 2000,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'streaming',
|
||||
response_time_ms: 5000,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
first_byte_time_ms: 1500,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'streaming',
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
first_byte_time_ms: 2000,
|
||||
})
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord({
|
||||
status: 'completed',
|
||||
response_time_ms: 5200,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
first_byte_time_ms: 1800,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(currentRecords.value[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
response_time_ms: 5200,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
first_byte_time_ms: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves client/detail metrics but clears stale final-provider facts from the next list snapshot', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
|
||||
@@ -217,9 +356,10 @@ describe('useUsageData', () => {
|
||||
has_retry: true,
|
||||
target_model: 'gpt-5.5',
|
||||
request_type: 'compact',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
actual_service_tier: 'priority',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'default',
|
||||
})
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
@@ -245,11 +385,12 @@ describe('useUsageData', () => {
|
||||
endpoint_api_format: undefined,
|
||||
has_format_conversion: undefined,
|
||||
has_retry: false,
|
||||
target_model: null,
|
||||
target_model: undefined,
|
||||
request_type: null,
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
actual_service_tier: null,
|
||||
requested_reasoning_effort: null,
|
||||
reasoning_effort: undefined,
|
||||
service_tier: undefined,
|
||||
actual_service_tier: undefined,
|
||||
})],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
@@ -280,11 +421,12 @@ describe('useUsageData', () => {
|
||||
endpoint_api_format: 'openai:responses',
|
||||
has_format_conversion: false,
|
||||
has_retry: true,
|
||||
target_model: 'gpt-5.5',
|
||||
target_model: null,
|
||||
request_type: 'compact',
|
||||
reasoning_effort: 'xhigh',
|
||||
service_tier: 'auto',
|
||||
actual_service_tier: 'priority',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: null,
|
||||
service_tier: null,
|
||||
actual_service_tier: null,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ import { createDefaultStats } from '../types'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getErrorStatus } from '@/types/api-error'
|
||||
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
|
||||
import {
|
||||
mergeUsageRecordErrorMessage,
|
||||
mergeUsageRecordFirstByteTimeMs,
|
||||
mergeUsageRecordResponseTiming,
|
||||
parseUsageTimestampMs,
|
||||
} from '../utils/recordSync'
|
||||
|
||||
export interface UseUsageDataOptions {
|
||||
isAdminPage: Ref<boolean>
|
||||
@@ -464,25 +470,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function mergePositiveDurationMs(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
): number | null | undefined {
|
||||
const existingIsPositive = typeof existingValue === 'number' && Number.isFinite(existingValue) && existingValue > 0
|
||||
const nextIsPositive = typeof nextValue === 'number' && Number.isFinite(nextValue) && nextValue > 0
|
||||
|
||||
if (existingIsPositive && nextIsPositive) {
|
||||
return Math.max(existingValue, nextValue)
|
||||
}
|
||||
if (existingIsPositive) {
|
||||
return existingValue
|
||||
}
|
||||
if (nextIsPositive) {
|
||||
return nextValue
|
||||
}
|
||||
return existingValue ?? nextValue
|
||||
}
|
||||
|
||||
function mergeSparseRecordMetric(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
@@ -539,10 +526,18 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
|
||||
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
|
||||
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
|
||||
const statusProgressed = hasNextStatus && (
|
||||
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
|
||||
const nextUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
|
||||
const nextStatusSnapshotIsStale = existingUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs < existingUpdatedAtMs
|
||||
const sameRankTerminalTransition = currentRank === 2 && nextRank === 2
|
||||
const statusProgressed = hasNextStatus && !nextStatusSnapshotIsStale && (
|
||||
!hasExistingStatus ||
|
||||
nextRank > currentRank ||
|
||||
(nextRank === currentRank && existing.status === record.status)
|
||||
(nextRank === currentRank && (
|
||||
existing.status === record.status || sameRankTerminalTransition
|
||||
))
|
||||
)
|
||||
const mergedStatus = statusProgressed ? record.status : existing.status
|
||||
|
||||
@@ -586,12 +581,27 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
? existing.client_requested_stream
|
||||
: undefined
|
||||
const clientIsStream = mergeBooleanTrueWins(existingClientIsStream, recordClientIsStream) ?? clientRequestedStream
|
||||
const nextTimingIsAuthoritative = statusProgressed &&
|
||||
(record.status === 'completed' || record.status === 'failed' || record.status === 'cancelled')
|
||||
const responseTiming = mergeUsageRecordResponseTiming(
|
||||
{
|
||||
response_time_ms: existing.response_time_ms,
|
||||
response_time_updated_at: existing.response_time_updated_at,
|
||||
},
|
||||
{
|
||||
response_time_ms: record.response_time_ms,
|
||||
response_time_updated_at: record.response_time_updated_at,
|
||||
},
|
||||
{ preferNext: nextTimingIsAuthoritative },
|
||||
)
|
||||
|
||||
return {
|
||||
...record,
|
||||
// 保留详情抽屉/活跃轮询已经拿到的完整指标,避免列表刷新用 0 或空值回退。
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
provider: statusProgressed
|
||||
? (protectProvider ? existing.provider : (record.provider || existing.provider))
|
||||
: existing.provider,
|
||||
input_tokens: mergeSparseRecordMetric(existing.input_tokens, record.input_tokens) ?? record.input_tokens,
|
||||
effective_input_tokens: mergeSparseRecordMetric(existing.effective_input_tokens, record.effective_input_tokens) ?? record.effective_input_tokens,
|
||||
output_tokens: mergeSparseRecordMetric(existing.output_tokens, record.output_tokens) ?? record.output_tokens,
|
||||
@@ -610,13 +620,31 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
cache_read_input_tokens: mergeSparseRecordMetric(existing.cache_read_input_tokens, record.cache_read_input_tokens) ?? record.cache_read_input_tokens,
|
||||
cost: mergeSparseRecordMetric(existing.cost, record.cost) ?? record.cost,
|
||||
actual_cost: mergeSparseRecordMetric(existing.actual_cost, record.actual_cost) ?? record.actual_cost,
|
||||
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||
updated_at: record.updated_at ?? existing.updated_at,
|
||||
response_time_updated_at: record.response_time_updated_at ?? existing.response_time_updated_at,
|
||||
status_code: record.status_code ?? existing.status_code,
|
||||
error_message: record.error_message ?? existing.error_message,
|
||||
image_progress: record.image_progress ?? existing.image_progress,
|
||||
response_time_ms: responseTiming.response_time_ms,
|
||||
first_byte_time_ms: mergeUsageRecordFirstByteTimeMs(
|
||||
existing.first_byte_time_ms,
|
||||
record.first_byte_time_ms,
|
||||
),
|
||||
updated_at: statusProgressed
|
||||
? (record.updated_at ?? existing.updated_at)
|
||||
: existing.updated_at,
|
||||
response_time_updated_at: responseTiming.response_time_updated_at,
|
||||
// Status, code and error are one lifecycle snapshot. An accepted full
|
||||
// list snapshot may clear an earlier candidate's 400/Cyber failure;
|
||||
// a rejected stale status snapshot must not mutate either field.
|
||||
status_code: statusProgressed
|
||||
? (record.status_code ?? undefined)
|
||||
: existing.status_code,
|
||||
error_message: statusProgressed
|
||||
? mergeUsageRecordErrorMessage(
|
||||
existing.error_message,
|
||||
record.error_message,
|
||||
{ authoritative: true },
|
||||
)
|
||||
: existing.error_message,
|
||||
image_progress: statusProgressed
|
||||
? (record.image_progress ?? existing.image_progress)
|
||||
: existing.image_progress,
|
||||
is_stream: upstreamIsStream,
|
||||
upstream_is_stream: upstreamIsStream,
|
||||
client_requested_stream: clientRequestedStream,
|
||||
@@ -627,13 +655,50 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
has_fallback: existing.has_fallback === true || record.has_fallback === true,
|
||||
has_retry: existing.has_retry === true || record.has_retry === true,
|
||||
api_key_name: record.api_key_name || existing.api_key_name,
|
||||
provider_key_name: record.provider_key_name || existing.provider_key_name,
|
||||
rate_multiplier: record.rate_multiplier ?? existing.rate_multiplier,
|
||||
target_model: record.target_model ?? existing.target_model,
|
||||
request_type: record.request_type ?? existing.request_type,
|
||||
reasoning_effort: record.reasoning_effort ?? existing.reasoning_effort,
|
||||
service_tier: record.service_tier ?? existing.service_tier,
|
||||
actual_service_tier: record.actual_service_tier ?? existing.actual_service_tier
|
||||
provider_key_name: statusProgressed
|
||||
? (record.provider_key_name || existing.provider_key_name)
|
||||
: existing.provider_key_name,
|
||||
rate_multiplier: statusProgressed
|
||||
? (record.rate_multiplier ?? existing.rate_multiplier)
|
||||
: existing.rate_multiplier,
|
||||
// Full list snapshots describe the final provider candidate. Missing/null means the
|
||||
// final request did not map the model and must clear an earlier candidate's arrow.
|
||||
target_model: statusProgressed
|
||||
? (typeof record.target_model === 'string' && record.target_model.trim()
|
||||
? record.target_model
|
||||
: null)
|
||||
: existing.target_model,
|
||||
// Request type is client-request identity, not a provider-candidate fact. Preserve a
|
||||
// known compact operation when a later sparse snapshot omits it.
|
||||
request_type:
|
||||
typeof record.request_type === 'string' && record.request_type.trim()
|
||||
? record.request_type
|
||||
: existing.request_type,
|
||||
requested_reasoning_effort:
|
||||
typeof record.requested_reasoning_effort === 'string'
|
||||
&& record.requested_reasoning_effort.trim()
|
||||
? record.requested_reasoning_effort
|
||||
: existing.requested_reasoning_effort,
|
||||
// Provider reasoning belongs to the final candidate just like service_tier; do not
|
||||
// retain a previous candidate's `max` when the final request has no reasoning field.
|
||||
reasoning_effort: statusProgressed
|
||||
? (typeof record.reasoning_effort === 'string' && record.reasoning_effort.trim()
|
||||
? record.reasoning_effort
|
||||
: null)
|
||||
: existing.reasoning_effort,
|
||||
// The list response is the authoritative snapshot of the final provider request. Do not
|
||||
// carry a tier forward when this response has no tier; doing so can leave a stale Fast
|
||||
// badge after the final upstream request falls back to Standard.
|
||||
service_tier: statusProgressed
|
||||
? (typeof record.service_tier === 'string' && record.service_tier.trim()
|
||||
? record.service_tier
|
||||
: null)
|
||||
: existing.service_tier,
|
||||
actual_service_tier: statusProgressed
|
||||
? (typeof record.actual_service_tier === 'string' && record.actual_service_tier.trim()
|
||||
? record.actual_service_tier
|
||||
: null)
|
||||
: existing.actual_service_tier
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,9 +97,10 @@ export interface UsageRecord {
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
|
||||
request_type?: string | null // 由请求语义识别出的操作类型
|
||||
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
|
||||
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
|
||||
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
|
||||
actual_service_tier?: string | null // Provider 响应确认的实际服务层级
|
||||
actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费
|
||||
api_format?: string
|
||||
endpoint_api_format?: string // 端点原生格式
|
||||
has_format_conversion?: boolean // 是否发生了格式转换
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isCyberPolicyError } from '../cyberError'
|
||||
|
||||
describe('isCyberPolicyError', () => {
|
||||
it('recognizes the provider cybersecurity refusal message', () => {
|
||||
expect(isCyberPolicyError({
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
code: 400,
|
||||
},
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes an explicit cyber_policy code', () => {
|
||||
expect(isCyberPolicyError({ error: { code: 'CYBER_POLICY' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes explicit Cyber Policy types and reasons', () => {
|
||||
expect(isCyberPolicyError({ error: { type: 'cyber_policy' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { type: 'CYBER' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { reason: 'cyber-policy' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { category: 'cyber_policy_violation' } })).toBe(true)
|
||||
expect(isCyberPolicyError({ error: { type: 'cybersecurity-risk' } })).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes structured Cyber classifiers inside a serialized error', () => {
|
||||
expect(isCyberPolicyError('{"error":{"type":"cyber"}}')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify ordinary invalid requests as Cyber Policy failures', () => {
|
||||
expect(isCyberPolicyError({
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'The request payload is malformed',
|
||||
code: 400,
|
||||
},
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('does not classify a generic use of the word cyber', () => {
|
||||
expect(isCyberPolicyError('The cyber security report was generated successfully')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UsageRecord } from '../../types'
|
||||
import {
|
||||
mergeUsageRecordErrorMessage,
|
||||
mergeUsageRecordFirstByteTimeMs,
|
||||
mergeUsageRecordLifecycleSnapshot,
|
||||
mergeUsageRecordResponseTiming,
|
||||
syncUsageRecordStreamResolution,
|
||||
} from '../recordSync'
|
||||
|
||||
@@ -75,3 +78,140 @@ describe('mergeUsageRecordFirstByteTimeMs', () => {
|
||||
expect(mergeUsageRecordFirstByteTimeMs(-1, null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeUsageRecordResponseTiming', () => {
|
||||
it('keeps duration and update timestamp as one monotonic active snapshot', () => {
|
||||
const existing = {
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}
|
||||
const stale = {
|
||||
response_time_ms: 5000,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming(existing, stale)).toBe(existing)
|
||||
})
|
||||
|
||||
it('accepts a live snapshot whose projected elapsed time has advanced', () => {
|
||||
const existing = {
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}
|
||||
const advanced = {
|
||||
response_time_ms: 7000,
|
||||
response_time_updated_at: '2026-07-17T12:00:07Z',
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming(existing, advanced)).toBe(advanced)
|
||||
})
|
||||
|
||||
it('does not combine an unanchored detail estimate with an existing anchor', () => {
|
||||
const existing = {
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}
|
||||
const detailEstimate = {
|
||||
response_time_ms: 6000,
|
||||
response_time_updated_at: null,
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming(existing, detailEstimate)).toBe(existing)
|
||||
})
|
||||
|
||||
it('lets a terminal snapshot replace the active estimate', () => {
|
||||
const terminal = {
|
||||
response_time_ms: 5200,
|
||||
response_time_updated_at: null,
|
||||
}
|
||||
|
||||
expect(mergeUsageRecordResponseTiming({
|
||||
response_time_ms: 5500,
|
||||
response_time_updated_at: '2026-07-17T12:00:06Z',
|
||||
}, terminal, { preferNext: true })).toBe(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeUsageRecordErrorMessage', () => {
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. Join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
|
||||
|
||||
it('keeps an authoritative Cyber Policy message when trace reports a generic error', () => {
|
||||
expect(mergeUsageRecordErrorMessage(
|
||||
cyberMessage,
|
||||
'execution runtime stream ended with a terminal error',
|
||||
)).toBe(cyberMessage)
|
||||
})
|
||||
|
||||
it('keeps an existing error when trace omits its error message', () => {
|
||||
expect(mergeUsageRecordErrorMessage(cyberMessage, undefined)).toBe(cyberMessage)
|
||||
expect(mergeUsageRecordErrorMessage(cyberMessage, null)).toBe(cyberMessage)
|
||||
expect(mergeUsageRecordErrorMessage(cyberMessage, ' ')).toBe(cyberMessage)
|
||||
})
|
||||
|
||||
it('accepts a Cyber Policy message discovered by trace', () => {
|
||||
expect(mergeUsageRecordErrorMessage('Request failed', cyberMessage)).toBe(cyberMessage)
|
||||
})
|
||||
|
||||
it('updates ordinary errors when the next snapshot has a more specific message', () => {
|
||||
expect(mergeUsageRecordErrorMessage('Request failed', 'rate limit exceeded'))
|
||||
.toBe('rate limit exceeded')
|
||||
expect(mergeUsageRecordErrorMessage(undefined, 'Request failed')).toBe('Request failed')
|
||||
})
|
||||
|
||||
it('lets an authoritative final-candidate snapshot replace or clear Cyber', () => {
|
||||
expect(mergeUsageRecordErrorMessage(
|
||||
cyberMessage,
|
||||
'rate limit exceeded',
|
||||
{ authoritative: true },
|
||||
)).toBe('rate limit exceeded')
|
||||
expect(mergeUsageRecordErrorMessage(
|
||||
cyberMessage,
|
||||
null,
|
||||
{ authoritative: true },
|
||||
)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeUsageRecordLifecycleSnapshot', () => {
|
||||
const cyberMessage = 'This content was flagged for possible cybersecurity risk. https://chatgpt.com/cyber'
|
||||
|
||||
it('rejects an older failed detail without changing status, code, or error', () => {
|
||||
expect(mergeUsageRecordLifecycleSnapshot({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
}, {
|
||||
status: 'failed',
|
||||
statusCode: 400,
|
||||
errorMessage: cyberMessage,
|
||||
updatedAt: '2026-07-17T00:00:01Z',
|
||||
})).toEqual({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
accepted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a newer completed detail and clears an earlier Cyber failure', () => {
|
||||
expect(mergeUsageRecordLifecycleSnapshot({
|
||||
status: 'failed',
|
||||
status_code: 400,
|
||||
error_message: cyberMessage,
|
||||
updated_at: '2026-07-17T00:00:01Z',
|
||||
}, {
|
||||
status: 'completed',
|
||||
statusCode: 200,
|
||||
errorMessage: null,
|
||||
updatedAt: '2026-07-17T00:00:02Z',
|
||||
})).toEqual({
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
error_message: undefined,
|
||||
updated_at: '2026-07-17T00:00:02Z',
|
||||
accepted: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from '../service-tier'
|
||||
|
||||
describe('service tier facts', () => {
|
||||
it('keeps requested, actual and billing tiers independent', () => {
|
||||
const facts = resolveServiceTierFacts({
|
||||
it('uses the final provider request tier for display and billing', () => {
|
||||
const source = {
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'default',
|
||||
settlement: {
|
||||
@@ -21,17 +21,28 @@ describe('service tier facts', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
const facts = resolveServiceTierFacts(source)
|
||||
|
||||
expect(facts).toEqual({ requested: 'priority', actual: 'default', billing: 'standard' })
|
||||
expect(facts).toEqual({ requested: 'priority' })
|
||||
expect(hasServiceTierFact(facts)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not infer billing from requested or actual tiers', () => {
|
||||
expect(resolveServiceTierFacts({
|
||||
service_tier: 'priority',
|
||||
it('does not infer a tier from the provider response or settlement snapshot', () => {
|
||||
const source = {
|
||||
actual_service_tier: 'flex',
|
||||
})).toEqual({ requested: 'priority', actual: 'flex', billing: null })
|
||||
settlement: {
|
||||
settlement_snapshot: {
|
||||
pricing_snapshot: {
|
||||
billing_processing_tier: 'priority',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const facts = resolveServiceTierFacts(source)
|
||||
expect(facts).toEqual({ requested: null })
|
||||
expect(hasServiceTierFact(facts)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes only non-empty string facts', () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const CYBER_POLICY_TEXT_MARKERS = [
|
||||
'possible cybersecurity risk',
|
||||
'trusted access for cyber',
|
||||
'chatgpt.com/cyber',
|
||||
]
|
||||
|
||||
const CYBER_POLICY_CLASSIFIER_FIELDS = [
|
||||
'code',
|
||||
'type',
|
||||
'category',
|
||||
'reason',
|
||||
] as const
|
||||
|
||||
const CYBER_ERROR_OBJECT_FIELDS = [
|
||||
'error',
|
||||
'errors',
|
||||
'message',
|
||||
'error_message',
|
||||
'detail',
|
||||
'body',
|
||||
'response_body',
|
||||
'upstream_error',
|
||||
'failure_summary',
|
||||
] as const
|
||||
|
||||
function normalizeCyberClassifier(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[\s-]+/g, '_')
|
||||
}
|
||||
|
||||
function isCyberPolicyClassifier(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
const normalized = normalizeCyberClassifier(value)
|
||||
if (normalized === 'cyber' || normalized === 'cyber_policy') return true
|
||||
|
||||
// Providers have used nearby classifier spellings while keeping the same
|
||||
// structured error contract. Keep this deliberately narrower than a generic
|
||||
// substring check so ordinary cybersecurity content is not badged.
|
||||
return /^(?:cyber|cybersecurity)_(?:policy|safety|risk)(?:_(?:violation|error|refusal|blocked))?$/.test(normalized)
|
||||
}
|
||||
|
||||
function isCyberPolicyText(value: string): boolean {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return CYBER_POLICY_TEXT_MARKERS.some(marker => normalized.includes(marker))
|
||||
|| /["'](?:code|type|category|reason)["']\s*:\s*["'](?:cyber|cyber[-_ ]policy|cyber[-_ ]safety|cybersecurity[-_ ](?:policy|risk))["']/i.test(normalized)
|
||||
}
|
||||
|
||||
function detectCyberPolicyError(value: unknown, seen: WeakSet<object>): boolean {
|
||||
if (typeof value === 'string') return isCyberPolicyText(value)
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) return value.some(item => detectCyberPolicyError(item, seen))
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (CYBER_POLICY_CLASSIFIER_FIELDS.some(field => isCyberPolicyClassifier(record[field]))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return CYBER_ERROR_OBJECT_FIELDS.some(field => detectCyberPolicyError(record[field], seen))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the provider's Cyber Policy refusal without treating generic HTTP 400,
|
||||
* invalid_request, or ordinary uses of the word "cyber" as policy failures.
|
||||
*/
|
||||
export function isCyberPolicyError(value: unknown): boolean {
|
||||
return detectCyberPolicyError(value, new WeakSet<object>())
|
||||
}
|
||||
@@ -1,10 +1,63 @@
|
||||
import type { UsageRecord } from '../types'
|
||||
import type { RequestStatus, UsageRecord } from '../types'
|
||||
import { isCyberPolicyError } from './cyberError'
|
||||
|
||||
export type UsageRecordStreamResolution = Pick<
|
||||
UsageRecord,
|
||||
'id' | 'is_stream' | 'upstream_is_stream' | 'client_requested_stream' | 'client_is_stream'
|
||||
>
|
||||
|
||||
export type UsageRecordResponseTiming = Pick<
|
||||
UsageRecord,
|
||||
'response_time_ms' | 'response_time_updated_at'
|
||||
>
|
||||
|
||||
function finiteNonNegativeDurationMs(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
export function parseUsageTimestampMs(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
const timestampMs = new Date(normalized).getTime()
|
||||
return Number.isFinite(timestampMs) ? timestampMs : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a live response duration together with the timestamp that anchors it.
|
||||
*
|
||||
* These fields form one clock snapshot: active elapsed time is projected as
|
||||
* `response_time_ms + (now - response_time_updated_at)`. Merging the larger
|
||||
* duration with a newer timestamp can therefore manufacture a shorter clock
|
||||
* that never existed. Keep the pair atomic and, while active, retain whichever
|
||||
* snapshot projects the larger elapsed value.
|
||||
*/
|
||||
export function mergeUsageRecordResponseTiming(
|
||||
existing: UsageRecordResponseTiming,
|
||||
next: UsageRecordResponseTiming,
|
||||
options: { preferNext?: boolean } = {},
|
||||
): UsageRecordResponseTiming {
|
||||
const existingDurationMs = finiteNonNegativeDurationMs(existing.response_time_ms)
|
||||
const nextDurationMs = finiteNonNegativeDurationMs(next.response_time_ms)
|
||||
|
||||
if (nextDurationMs == null) return existing
|
||||
if (options.preferNext || existingDurationMs == null) return next
|
||||
|
||||
const existingUpdatedAtMs = parseUsageTimestampMs(existing.response_time_updated_at)
|
||||
const nextUpdatedAtMs = parseUsageTimestampMs(next.response_time_updated_at)
|
||||
|
||||
if (existingUpdatedAtMs != null && nextUpdatedAtMs != null) {
|
||||
const existingStartedAtMs = existingUpdatedAtMs - existingDurationMs
|
||||
const nextStartedAtMs = nextUpdatedAtMs - nextDurationMs
|
||||
return nextStartedAtMs <= existingStartedAtMs ? next : existing
|
||||
}
|
||||
|
||||
// An anchored snapshot is safer than an unanchored duration for a live clock.
|
||||
if (existingUpdatedAtMs != null) return existing
|
||||
if (nextUpdatedAtMs != null) return next
|
||||
|
||||
return nextDurationMs >= existingDurationMs ? next : existing
|
||||
}
|
||||
|
||||
export function mergeUsageRecordFirstByteTimeMs(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
@@ -29,6 +82,109 @@ export function mergeUsageRecordFirstByteTimeMs(
|
||||
return existingValue == null ? existingValue : undefined
|
||||
}
|
||||
|
||||
export function mergeUsageRecordErrorMessage(
|
||||
existingValue: string | null | undefined,
|
||||
nextValue: string | null | undefined,
|
||||
options: { authoritative?: boolean } = {},
|
||||
): string | undefined {
|
||||
const existing = typeof existingValue === 'string' && existingValue.trim()
|
||||
? existingValue
|
||||
: undefined
|
||||
const next = typeof nextValue === 'string' && nextValue.trim()
|
||||
? nextValue
|
||||
: undefined
|
||||
|
||||
// Complete list/active snapshots describe the current final candidate. They
|
||||
// must be able to replace *and clear* an error left by an earlier candidate.
|
||||
if (options.authoritative) return next
|
||||
|
||||
if (!next) return existing
|
||||
|
||||
// Detail/trace snapshots may carry a generic runtime message. Do not let that
|
||||
// downgrade a provider Cyber Policy refusal already resolved by the usage list.
|
||||
if (isCyberPolicyError(existing) && !isCyberPolicyError(next)) return existing
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export type UsageRecordLifecycleSnapshot = Pick<
|
||||
UsageRecord,
|
||||
'status' | 'status_code' | 'error_message' | 'updated_at'
|
||||
>
|
||||
|
||||
export type UsageRecordLifecycleUpdate = {
|
||||
status?: RequestStatus
|
||||
statusCode?: number | null
|
||||
errorMessage?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the sparse lifecycle state emitted by the detail drawer.
|
||||
*
|
||||
* Status, status code and error belong to one snapshot. If a detail response
|
||||
* is older (or its status would regress), none of those fields may leak into
|
||||
* the newer row. A completed/cancelled or explicitly newer terminal snapshot
|
||||
* is authoritative for errors; a same-snapshot generic failure still keeps a
|
||||
* provider Cyber refusal already known by the list.
|
||||
*/
|
||||
export function mergeUsageRecordLifecycleSnapshot(
|
||||
existing: UsageRecordLifecycleSnapshot,
|
||||
update: UsageRecordLifecycleUpdate,
|
||||
): UsageRecordLifecycleSnapshot & { accepted: boolean } {
|
||||
const statusPriority: Record<RequestStatus, number> = {
|
||||
pending: 0,
|
||||
streaming: 1,
|
||||
completed: 2,
|
||||
failed: 2,
|
||||
cancelled: 2,
|
||||
}
|
||||
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
|
||||
const nextUpdatedAtMs = parseUsageTimestampMs(update.updatedAt)
|
||||
const nextSnapshotIsOlder = existingUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs < existingUpdatedAtMs
|
||||
const currentRank = existing.status ? statusPriority[existing.status] : -1
|
||||
const nextRank = update.status ? statusPriority[update.status] : -1
|
||||
const statusAccepted = update.status != null &&
|
||||
!nextSnapshotIsOlder &&
|
||||
nextRank >= currentRank
|
||||
const accepted = !nextSnapshotIsOlder && (update.status == null || statusAccepted)
|
||||
|
||||
if (!accepted) {
|
||||
return { ...existing, accepted: false }
|
||||
}
|
||||
|
||||
const terminalSnapshotIsStrictlyNewer = statusAccepted &&
|
||||
(update.status === 'completed' || update.status === 'failed' || update.status === 'cancelled') &&
|
||||
existingUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs != null &&
|
||||
nextUpdatedAtMs > existingUpdatedAtMs
|
||||
const errorIsAuthoritative = statusAccepted && (
|
||||
update.status === 'completed' ||
|
||||
update.status === 'cancelled' ||
|
||||
terminalSnapshotIsStrictlyNewer
|
||||
)
|
||||
const hasStatusCode = Object.prototype.hasOwnProperty.call(update, 'statusCode')
|
||||
const hasErrorMessage = Object.prototype.hasOwnProperty.call(update, 'errorMessage')
|
||||
|
||||
return {
|
||||
status: statusAccepted ? update.status : existing.status,
|
||||
status_code: hasStatusCode ? (update.statusCode ?? undefined) : existing.status_code,
|
||||
error_message: hasErrorMessage
|
||||
? mergeUsageRecordErrorMessage(
|
||||
existing.error_message,
|
||||
update.errorMessage,
|
||||
{ authoritative: errorIsAuthoritative },
|
||||
)
|
||||
: existing.error_message,
|
||||
updated_at: typeof update.updatedAt === 'string'
|
||||
? update.updatedAt
|
||||
: existing.updated_at,
|
||||
accepted: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUsageRecordStreamResolution(
|
||||
records: UsageRecord[],
|
||||
resolved: UsageRecordStreamResolution
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
export interface ServiceTierFacts {
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
}
|
||||
|
||||
export interface ServiceTierFactSource {
|
||||
service_tier?: unknown
|
||||
actual_service_tier?: unknown
|
||||
settlement?: unknown
|
||||
}
|
||||
|
||||
export function resolveServiceTierFacts(
|
||||
source: ServiceTierFactSource | null | undefined,
|
||||
): ServiceTierFacts {
|
||||
const settlement = asRecord(source?.settlement)
|
||||
const settlementSnapshot = asRecord(settlement?.settlement_snapshot)
|
||||
const pricingSnapshot = asRecord(settlementSnapshot?.pricing_snapshot)
|
||||
// The processing tier is an input-side fact: it must come from the final
|
||||
// request body sent to the provider. Response-advertised tiers and old
|
||||
// settlement snapshots can describe a different/legacy value, so they are
|
||||
// deliberately not consulted here. The billing display uses this same
|
||||
// authoritative request tier.
|
||||
return {
|
||||
requested: normalizeServiceTierFact(source?.service_tier),
|
||||
actual: normalizeServiceTierFact(source?.actual_service_tier),
|
||||
billing: normalizeServiceTierFact(pricingSnapshot?.billing_processing_tier),
|
||||
}
|
||||
}
|
||||
|
||||
export function hasServiceTierFact(facts: ServiceTierFacts): boolean {
|
||||
return facts.requested !== null || facts.actual !== null || facts.billing !== null
|
||||
return facts.requested !== null
|
||||
}
|
||||
|
||||
export function normalizeServiceTierFact(value: unknown): string | null {
|
||||
@@ -47,9 +43,3 @@ export function formatServiceTierFact(value: unknown): string | null {
|
||||
? 'Fast'
|
||||
: normalized
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildApiKeyRedactionFeatureSettingsPatch,
|
||||
resolveApiKeyRedactionFormState,
|
||||
} from '../apiKeyFeatureSettings'
|
||||
|
||||
const redaction = {
|
||||
enabled: true,
|
||||
inject_model_instruction: false,
|
||||
}
|
||||
|
||||
describe('managed API key feature setting inheritance', () => {
|
||||
it('uses the target user value for an inherited key form', () => {
|
||||
expect(resolveApiKeyRedactionFormState(null, {
|
||||
chat_pii_redaction: redaction,
|
||||
})).toEqual({
|
||||
mode: 'inherit',
|
||||
...redaction,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits feature_settings when a created key keeps inheritance', () => {
|
||||
expect(buildApiKeyRedactionFeatureSettingsPatch({
|
||||
isEditing: false,
|
||||
currentFeatureSettings: undefined,
|
||||
mode: 'inherit',
|
||||
value: redaction,
|
||||
})).toEqual({})
|
||||
})
|
||||
|
||||
it('writes an override only when custom mode is selected', () => {
|
||||
expect(buildApiKeyRedactionFeatureSettingsPatch({
|
||||
isEditing: false,
|
||||
currentFeatureSettings: undefined,
|
||||
mode: 'custom',
|
||||
value: redaction,
|
||||
})).toEqual({
|
||||
feature_settings: {
|
||||
chat_pii_redaction: redaction,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create an override when only another field of an inherited key changes', () => {
|
||||
expect(buildApiKeyRedactionFeatureSettingsPatch({
|
||||
isEditing: true,
|
||||
currentFeatureSettings: null,
|
||||
mode: 'inherit',
|
||||
value: redaction,
|
||||
})).toEqual({})
|
||||
})
|
||||
|
||||
it('removes only the existing redaction override when inheritance is restored', () => {
|
||||
expect(buildApiKeyRedactionFeatureSettingsPatch({
|
||||
isEditing: true,
|
||||
currentFeatureSettings: {
|
||||
chat_pii_redaction: { enabled: false },
|
||||
notification_push_service: { enabled: true },
|
||||
},
|
||||
mode: 'inherit',
|
||||
value: redaction,
|
||||
})).toEqual({
|
||||
feature_settings: {
|
||||
notification_push_service: { enabled: true },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FeatureSettings } from '@/api/users'
|
||||
import {
|
||||
hasChatPiiRedactionFeatureSettings,
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
readChatPiiRedactionFeatureSettings,
|
||||
removeChatPiiRedactionFeatureSettings,
|
||||
type ChatPiiRedactionFeatureSettings,
|
||||
} from '@/utils/featureSettings'
|
||||
|
||||
export type ApiKeyRedactionMode = 'inherit' | 'custom'
|
||||
|
||||
export interface ApiKeyRedactionFormState extends ChatPiiRedactionFeatureSettings {
|
||||
mode: ApiKeyRedactionMode
|
||||
}
|
||||
|
||||
export function resolveApiKeyRedactionFormState(
|
||||
apiKeyFeatureSettings: FeatureSettings | null | undefined,
|
||||
inheritedUserFeatureSettings: FeatureSettings | null | undefined,
|
||||
): ApiKeyRedactionFormState {
|
||||
const hasCustomRedaction = hasChatPiiRedactionFeatureSettings(apiKeyFeatureSettings)
|
||||
const value = readChatPiiRedactionFeatureSettings(
|
||||
hasCustomRedaction ? apiKeyFeatureSettings : inheritedUserFeatureSettings,
|
||||
)
|
||||
return {
|
||||
mode: hasCustomRedaction ? 'custom' : 'inherit',
|
||||
...value,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds only the feature-settings portion of an API-key mutation.
|
||||
*
|
||||
* An omitted field preserves inheritance. `null` (or an object with the
|
||||
* redaction key removed) is emitted only when an existing custom override is
|
||||
* explicitly switched back to inheritance.
|
||||
*/
|
||||
export function buildApiKeyRedactionFeatureSettingsPatch(options: {
|
||||
isEditing: boolean
|
||||
currentFeatureSettings: FeatureSettings | null | undefined
|
||||
mode: ApiKeyRedactionMode
|
||||
value: ChatPiiRedactionFeatureSettings
|
||||
}): { feature_settings?: FeatureSettings | null } {
|
||||
if (options.mode === 'custom') {
|
||||
return {
|
||||
feature_settings: mergeChatPiiRedactionFeatureSettings(
|
||||
options.isEditing ? options.currentFeatureSettings : null,
|
||||
options.value,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
options.isEditing
|
||||
&& hasChatPiiRedactionFeatureSettings(options.currentFeatureSettings)
|
||||
) {
|
||||
return {
|
||||
feature_settings: removeChatPiiRedactionFeatureSettings(
|
||||
options.currentFeatureSettings,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -104,15 +104,47 @@
|
||||
|
||||
<div class="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">
|
||||
{{ legacyT('敏感信息保护') }}
|
||||
</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ legacyT(form.chat_pii_redaction_mode === 'inherit' ? '跟随目标用户设置' : '仅覆盖此 API Key') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="form.chat_pii_redaction_mode === 'inherit' ? 'default' : 'outline'"
|
||||
@click="updateField('chat_pii_redaction_mode', 'inherit')"
|
||||
>
|
||||
{{ legacyT('跟随用户') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="form.chat_pii_redaction_mode === 'custom' ? 'default' : 'outline'"
|
||||
@click="updateField('chat_pii_redaction_mode', 'custom')"
|
||||
>
|
||||
{{ legacyT('单独配置') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.chat_pii_redaction_mode === 'custom'"
|
||||
class="flex items-center justify-between gap-3 border-t border-border/50 pt-3"
|
||||
>
|
||||
<Label class="text-sm font-medium">
|
||||
{{ legacyT('敏感信息保护') }}
|
||||
{{ legacyT('启用保护') }}
|
||||
</Label>
|
||||
<Switch
|
||||
:model-value="form.chat_pii_redaction_enabled"
|
||||
@update:model-value="updateField('chat_pii_redaction_enabled', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div
|
||||
v-if="form.chat_pii_redaction_mode === 'custom' && form.chat_pii_redaction_enabled"
|
||||
class="flex items-center justify-between gap-3 border-t border-border/50 pt-3"
|
||||
>
|
||||
<Label class="text-sm font-medium">
|
||||
{{ legacyT('占位符说明') }}
|
||||
</Label>
|
||||
@@ -156,6 +188,7 @@ export interface UserApiKeyFormState {
|
||||
rate_limit?: number
|
||||
concurrent_limit?: number
|
||||
ip_rules_text: string
|
||||
chat_pii_redaction_mode: 'inherit' | 'custom'
|
||||
chat_pii_redaction_enabled: boolean
|
||||
chat_pii_redaction_placeholder_notice: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => true,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||
import { handleMockRequest, setMockUserToken } from '../handler'
|
||||
|
||||
interface MockPoolKey {
|
||||
key_id: string
|
||||
oauth_plan_type: string
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
windows: QuotaWindowSnapshot[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('pool quota demo contracts', () => {
|
||||
beforeEach(() => {
|
||||
setMockUserToken('demo-access-token-admin')
|
||||
})
|
||||
|
||||
it('exposes a dedicated Codex pool in the overview and provider summary', async () => {
|
||||
const overviewResponse = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/pool/overview',
|
||||
})
|
||||
const overview = overviewResponse?.data as {
|
||||
items: Array<{ provider_id: string; provider_type: string; total_keys: number }>
|
||||
}
|
||||
const provider = overview.items[0]
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
provider_id: 'provider-codex-pool-demo',
|
||||
provider_type: 'codex',
|
||||
total_keys: 4,
|
||||
})
|
||||
|
||||
const summaryResponse = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: `/api/admin/providers/${provider.provider_id}/summary`,
|
||||
})
|
||||
expect(summaryResponse?.data).toMatchObject({
|
||||
id: provider.provider_id,
|
||||
provider_type: 'codex',
|
||||
name: 'Codex 周期额度演示',
|
||||
})
|
||||
})
|
||||
|
||||
it('covers dual, weekly-only, monthly-only, and 5H-only quota windows', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/pool/provider-codex-pool-demo/keys',
|
||||
params: { page: 1, page_size: 50, status: 'all' },
|
||||
})
|
||||
const page = response?.data as { total: number; keys: MockPoolKey[] }
|
||||
const keys = new Map(page.keys.map(key => [key.key_id, key]))
|
||||
const labelsFor = (keyId: string) => keys.get(keyId)?.status_snapshot.quota.windows
|
||||
.map(getCodexQuotaWindowPresentation)
|
||||
.filter((item): item is NonNullable<typeof item> => item != null)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map(item => item.label)
|
||||
|
||||
expect(page.total).toBe(4)
|
||||
expect(labelsFor('codex-pool-plus-dual')).toEqual(['5H', '周'])
|
||||
expect(labelsFor('codex-pool-team-weekly')).toEqual(['周'])
|
||||
expect(labelsFor('codex-pool-business-monthly')).toEqual(['月'])
|
||||
expect(labelsFor('codex-pool-free-five-hour')).toEqual(['5H'])
|
||||
expect(keys.get('codex-pool-business-monthly')?.oauth_plan_type)
|
||||
.toBe('self_serve_business_usage_based')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => true,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
import { handleMockRequest, setMockUserToken } from '../handler'
|
||||
|
||||
describe('provider detail demo contracts', () => {
|
||||
beforeEach(() => {
|
||||
setMockUserToken('demo-access-token-admin')
|
||||
})
|
||||
|
||||
it('returns the paginated key contract used by the provider drawer', async () => {
|
||||
const firstPageResponse = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/endpoints/providers/provider-004/keys',
|
||||
params: { page: 1, page_size: 1 },
|
||||
})
|
||||
const secondPageResponse = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/endpoints/providers/provider-004/keys',
|
||||
params: { page: 2, page_size: 1 },
|
||||
})
|
||||
const firstPage = firstPageResponse?.data as {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
keys: Array<{ id: string }>
|
||||
}
|
||||
const secondPage = secondPageResponse?.data as typeof firstPage
|
||||
|
||||
expect(firstPage).toMatchObject({ total: 2, page: 1, page_size: 1 })
|
||||
expect(firstPage.keys).toHaveLength(1)
|
||||
expect(secondPage).toMatchObject({ total: 2, page: 2, page_size: 1 })
|
||||
expect(secondPage.keys).toHaveLength(1)
|
||||
expect(secondPage.keys[0]?.id).not.toBe(firstPage.keys[0]?.id)
|
||||
})
|
||||
|
||||
it('preserves the legacy array contract for skip/limit callers', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/endpoints/providers/provider-004/keys',
|
||||
params: { skip: 1, limit: 1 },
|
||||
})
|
||||
|
||||
expect(Array.isArray(response?.data)).toBe(true)
|
||||
expect(response?.data).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns a complete mapping-preview envelope', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/providers/provider-004/mapping-preview',
|
||||
})
|
||||
|
||||
expect(response?.data).toEqual({
|
||||
provider_id: 'provider-004',
|
||||
provider_name: 'IKunCode',
|
||||
keys: [],
|
||||
total_keys: 0,
|
||||
total_matches: 0,
|
||||
truncated: false,
|
||||
truncated_keys: 0,
|
||||
truncated_models: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => true,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
import { handleMockRequest, setMockUserToken } from '../handler'
|
||||
|
||||
describe('usage detail demo contracts', () => {
|
||||
beforeEach(() => {
|
||||
setMockUserToken('demo-access-token-admin')
|
||||
})
|
||||
|
||||
it('keeps body availability while omitting bodies from lightweight detail', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/usage/usage-cyber-risk-demo',
|
||||
params: { include_bodies: false },
|
||||
})
|
||||
|
||||
expect(response?.data).toMatchObject({
|
||||
has_request_body: true,
|
||||
has_provider_request_body: true,
|
||||
has_response_body: true,
|
||||
request_body: null,
|
||||
provider_request_body: null,
|
||||
response_body: null,
|
||||
client_response_body: null,
|
||||
body_load_errors: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the exact Cyber error body when bodies are requested', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/usage/usage-cyber-risk-demo',
|
||||
params: { include_bodies: true },
|
||||
})
|
||||
|
||||
expect(response?.data?.response_body).toEqual({
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber',
|
||||
code: 400,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => true,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
import { handleMockRequest, setMockUserToken } from '../handler'
|
||||
|
||||
describe('user management demo contracts', () => {
|
||||
beforeEach(() => {
|
||||
setMockUserToken('demo-access-token-admin')
|
||||
})
|
||||
|
||||
it('returns a list-shaped user group response', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/user-groups',
|
||||
})
|
||||
|
||||
expect(response?.data).toEqual({
|
||||
items: [],
|
||||
default_group_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates and lists managed keys only for the selected target user', async () => {
|
||||
const aliceId = 'demo-user-uuid-0003'
|
||||
const bobId = 'demo-user-uuid-0004'
|
||||
const created = await handleMockRequest({
|
||||
method: 'POST',
|
||||
url: `/api/admin/users/${aliceId}/api-keys`,
|
||||
data: JSON.stringify({ name: 'Alice inherited key' }),
|
||||
})
|
||||
|
||||
expect(created?.data).toMatchObject({
|
||||
name: 'Alice inherited key',
|
||||
feature_settings: null,
|
||||
is_standalone: false,
|
||||
})
|
||||
expect(created?.data?.key).toMatch(/^sk-ae-demo-/)
|
||||
|
||||
const aliceKeys = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: `/api/admin/users/${aliceId}/api-keys`,
|
||||
})
|
||||
const bobKeys = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: `/api/admin/users/${bobId}/api-keys`,
|
||||
})
|
||||
|
||||
expect(aliceKeys?.data).toMatchObject({ total: 1 })
|
||||
expect(aliceKeys?.data?.api_keys).toHaveLength(1)
|
||||
expect(aliceKeys?.data?.api_keys[0]).toMatchObject({ name: 'Alice inherited key' })
|
||||
expect(aliceKeys?.data?.api_keys[0]).not.toHaveProperty('key')
|
||||
expect(aliceKeys?.data?.api_keys[0]).not.toHaveProperty('fullKey')
|
||||
expect(bobKeys?.data).toEqual({ api_keys: [], total: 0 })
|
||||
})
|
||||
})
|
||||
+656
-11
@@ -705,6 +705,77 @@ function getActivityHeatmap() {
|
||||
return cachedHeatmap
|
||||
}
|
||||
|
||||
const MOCK_CYBER_POLICY_USAGE_ID = 'usage-cyber-risk-demo'
|
||||
const MOCK_CYBER_POLICY_ERROR_MESSAGE = 'This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber'
|
||||
const MOCK_CYBER_POLICY_ERROR_BODY = {
|
||||
error: {
|
||||
type: 'invalid_request',
|
||||
message: MOCK_CYBER_POLICY_ERROR_MESSAGE,
|
||||
code: 400
|
||||
}
|
||||
}
|
||||
|
||||
interface MockManagedUserApiKey {
|
||||
id: string
|
||||
fullKey: string
|
||||
key_display: string
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
is_active: boolean
|
||||
is_locked: boolean
|
||||
is_standalone: false
|
||||
feature_settings?: Record<string, unknown> | null
|
||||
rate_limit?: number | null
|
||||
concurrent_limit?: number | null
|
||||
ip_rules?: string[] | null
|
||||
total_requests: number
|
||||
total_cost_usd: number
|
||||
force_capabilities?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const mockManagedUserApiKeysByUserId = new Map<string, MockManagedUserApiKey[]>([
|
||||
[MOCK_NORMAL_USER.id ?? '', MOCK_USER_API_KEYS.map((key, index) => ({
|
||||
...key,
|
||||
fullKey: `sk-ae-demo-user-${index + 1}`,
|
||||
is_locked: false,
|
||||
is_standalone: false as const,
|
||||
}))],
|
||||
])
|
||||
let mockManagedUserApiKeySequence = 0
|
||||
|
||||
function mockManagedUserApiKeys(userId: string): MockManagedUserApiKey[] {
|
||||
if (!MOCK_ALL_USERS.some(user => user.id === userId)) {
|
||||
throw { response: createMockResponse({ detail: '用户不存在' }, 404) }
|
||||
}
|
||||
let keys = mockManagedUserApiKeysByUserId.get(userId)
|
||||
if (!keys) {
|
||||
keys = []
|
||||
mockManagedUserApiKeysByUserId.set(userId, keys)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function publicMockManagedUserApiKey(key: MockManagedUserApiKey) {
|
||||
const { fullKey: _fullKey, ...publicKey } = key
|
||||
void _fullKey
|
||||
return publicKey
|
||||
}
|
||||
|
||||
function mockRequestObject(config: AxiosRequestConfig): Record<string, unknown> {
|
||||
if (typeof config.data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(config.data)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return config.data && typeof config.data === 'object' && !Array.isArray(config.data)
|
||||
? config.data as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
// 生成更真实的使用记录
|
||||
function generateMockUsageRecords(count: number = 100) {
|
||||
const records = []
|
||||
@@ -798,6 +869,47 @@ function generateMockUsageRecords(count: number = 100) {
|
||||
})
|
||||
}
|
||||
|
||||
// 固定在首屏的失败记录,用于预览候选链路中的实际上游错误响应。
|
||||
records.unshift({
|
||||
id: MOCK_CYBER_POLICY_USAGE_ID,
|
||||
user_id: 'demo-admin-uuid-0001',
|
||||
username: 'Demo Admin',
|
||||
user_email: 'admin@demo.aether.ai',
|
||||
api_key: {
|
||||
id: 'key-demo-cyber-risk',
|
||||
name: 'OpenAI Cyber Risk Demo',
|
||||
display: 'sk-ae...demo'
|
||||
},
|
||||
provider: 'openai',
|
||||
api_key_name: 'openai-cyber-risk-demo',
|
||||
rate_multiplier: 1.0,
|
||||
model: 'gpt-5',
|
||||
target_model: 'gpt-5.1',
|
||||
requested_reasoning_effort: 'xhigh',
|
||||
reasoning_effort: 'max',
|
||||
service_tier: 'priority',
|
||||
// Deliberately conflicts with the final provider request. UI and billing
|
||||
// must use the request-side `service_tier`, never this response fact.
|
||||
actual_service_tier: 'default',
|
||||
api_format: 'openai:responses',
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_tokens: 0,
|
||||
cost: 0,
|
||||
actual_cost: 0,
|
||||
response_time_ms: 428,
|
||||
is_stream: true,
|
||||
status_code: 400,
|
||||
error_message: MOCK_CYBER_POLICY_ERROR_MESSAGE,
|
||||
status: 'failed',
|
||||
created_at: new Date(now).toISOString(),
|
||||
updated_at: new Date(now).toISOString(),
|
||||
has_fallback: false,
|
||||
model_version: undefined
|
||||
})
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
@@ -979,6 +1091,204 @@ const MOCK_CAPABILITIES = [
|
||||
{ name: 'context_1m', display_name: '1M上下文', description: '支持1M上下文窗口', match_mode: 'compatible', short_name: '1M' }
|
||||
]
|
||||
|
||||
const MOCK_CODEX_POOL_PROVIDER_ID = 'provider-codex-pool-demo'
|
||||
const MOCK_CODEX_POOL_PROVIDER = {
|
||||
id: MOCK_CODEX_POOL_PROVIDER_ID,
|
||||
name: 'Codex 周期额度演示',
|
||||
provider_type: 'codex',
|
||||
description: '展示 5H、周、月及组合额度窗口',
|
||||
website: 'https://openai.com/codex',
|
||||
provider_priority: 0,
|
||||
billing_type: 'free_tier',
|
||||
monthly_used_usd: 0,
|
||||
is_active: true,
|
||||
total_endpoints: 1,
|
||||
active_endpoints: 1,
|
||||
total_keys: 4,
|
||||
active_keys: 4,
|
||||
total_models: 3,
|
||||
active_models: 3,
|
||||
avg_health_score: 0.97,
|
||||
unhealthy_endpoints: 0,
|
||||
api_formats: ['openai:responses'],
|
||||
endpoint_health_details: [
|
||||
{ api_format: 'openai:responses', health_score: 0.97, is_active: true, active_keys: 4 }
|
||||
],
|
||||
pool_advanced: {
|
||||
enabled: true,
|
||||
probing_enabled: true,
|
||||
},
|
||||
claude_code_advanced: null,
|
||||
proxy: null,
|
||||
created_at: '2026-07-01T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
function createMockCodexQuotaWindow(
|
||||
code: string,
|
||||
label: string,
|
||||
windowMinutes: number,
|
||||
remainingRatio: number,
|
||||
resetSeconds: number,
|
||||
observedAt: number,
|
||||
requestCount: number,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
label,
|
||||
scope: 'account',
|
||||
unit: 'percent',
|
||||
used_ratio: 1 - remainingRatio,
|
||||
remaining_ratio: remainingRatio,
|
||||
reset_at: resetSeconds > 0 ? observedAt + resetSeconds : null,
|
||||
reset_seconds: resetSeconds,
|
||||
window_minutes: windowMinutes,
|
||||
usage: {
|
||||
request_count: requestCount,
|
||||
total_tokens: requestCount * 1250,
|
||||
total_cost_usd: (requestCount * 0.0025).toFixed(8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCodexPoolKeys() {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const common = {
|
||||
provider_type: 'codex',
|
||||
is_active: true,
|
||||
auth_type: 'oauth',
|
||||
credential_kind: 'oauth_session',
|
||||
runtime_auth_kind: 'bearer',
|
||||
oauth_managed: true,
|
||||
oauth_header_auth: true,
|
||||
can_refresh_oauth: true,
|
||||
can_export_oauth: true,
|
||||
can_edit_oauth: true,
|
||||
oauth_expires_at: nowSeconds + 14 * 24 * 3600,
|
||||
api_formats: ['openai:responses'],
|
||||
rate_multipliers: null,
|
||||
internal_priority: 50,
|
||||
rpm_limit: null,
|
||||
cache_ttl_minutes: 5,
|
||||
max_probe_interval_minutes: 32,
|
||||
health_score: 0.97,
|
||||
circuit_breaker_open: false,
|
||||
proxy: null,
|
||||
cooldown_reason: null,
|
||||
cooldown_ttl_seconds: null,
|
||||
cost_window_usage: 0,
|
||||
cost_limit: null,
|
||||
sticky_sessions: 0,
|
||||
lru_score: null,
|
||||
created_at: '2026-07-01T00:00:00Z',
|
||||
imported_at: '2026-07-01T00:00:00Z',
|
||||
last_used_at: new Date(nowSeconds * 1000 - 10 * 60 * 1000).toISOString(),
|
||||
scheduling_status: 'available',
|
||||
scheduling_reason: 'available',
|
||||
scheduling_label: '可调度',
|
||||
scheduling_reasons: [],
|
||||
}
|
||||
|
||||
const buildKey = (
|
||||
keyId: string,
|
||||
keyName: string,
|
||||
planType: string,
|
||||
accountQuota: string,
|
||||
windows: ReturnType<typeof createMockCodexQuotaWindow>[],
|
||||
requestCount: number,
|
||||
) => ({
|
||||
...common,
|
||||
key_id: keyId,
|
||||
key_name: keyName,
|
||||
oauth_plan_type: planType,
|
||||
oauth_account_id: `acct-${keyId}`,
|
||||
oauth_account_name: keyName,
|
||||
quota_updated_at: nowSeconds - 10 * 60,
|
||||
account_quota: accountQuota,
|
||||
request_count: requestCount,
|
||||
total_tokens: requestCount * 2400,
|
||||
total_cost_usd: (requestCount * 0.004).toFixed(8),
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'valid',
|
||||
label: '有效',
|
||||
expires_at: nowSeconds + 14 * 24 * 3600,
|
||||
requires_reauth: false,
|
||||
expiring_soon: false,
|
||||
},
|
||||
account: {
|
||||
code: 'ok',
|
||||
label: null,
|
||||
reason: null,
|
||||
blocked: false,
|
||||
source: null,
|
||||
recoverable: false,
|
||||
},
|
||||
quota: {
|
||||
version: 2,
|
||||
provider_type: 'codex',
|
||||
code: 'ok',
|
||||
label: null,
|
||||
reason: null,
|
||||
freshness: 'fresh',
|
||||
source: 'response_headers',
|
||||
observed_at: nowSeconds,
|
||||
updated_at: nowSeconds,
|
||||
exhausted: false,
|
||||
usage_ratio: windows.reduce((max, window) => Math.max(max, window.used_ratio), 0),
|
||||
plan_type: planType,
|
||||
credits: { has_credits: false, unlimited: false },
|
||||
windows,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return [
|
||||
buildKey(
|
||||
'codex-pool-plus-dual',
|
||||
'Plus · 5H + 周',
|
||||
'plus',
|
||||
'5H剩余 62.0% | 周剩余 84.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('5h', '5H', 300, 0.62, 3 * 3600, nowSeconds, 18),
|
||||
createMockCodexQuotaWindow('weekly', '周', 10_080, 0.84, 5 * 24 * 3600, nowSeconds, 42),
|
||||
],
|
||||
128,
|
||||
),
|
||||
buildKey(
|
||||
'codex-pool-team-weekly',
|
||||
'Team · 仅周',
|
||||
'team',
|
||||
'周剩余 71.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('weekly', '周', 10_080, 0.71, 4 * 24 * 3600, nowSeconds, 31),
|
||||
],
|
||||
96,
|
||||
),
|
||||
buildKey(
|
||||
'codex-pool-business-monthly',
|
||||
'Codex · 仅月(含空占位)',
|
||||
'self_serve_business_usage_based',
|
||||
'月剩余 86.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('monthly', '月', 43_800, 0.86, 2_627_672, nowSeconds, 54),
|
||||
createMockCodexQuotaWindow('weekly', '周', 0, 1, 0, nowSeconds, 0),
|
||||
],
|
||||
214,
|
||||
),
|
||||
buildKey(
|
||||
'codex-pool-free-five-hour',
|
||||
'Free · 仅5H',
|
||||
'free',
|
||||
'5H剩余 93.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('5h', '5H', 300, 0.93, 4 * 3600, nowSeconds, 7),
|
||||
],
|
||||
37,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock API 路由处理器
|
||||
*/
|
||||
@@ -1330,6 +1640,15 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
return createMockResponse(MOCK_ALL_USERS)
|
||||
},
|
||||
|
||||
'GET /api/admin/user-groups': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: [],
|
||||
default_group_id: null,
|
||||
})
|
||||
},
|
||||
|
||||
'POST /api/admin/users': async (config) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
@@ -1378,7 +1697,12 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
'GET /api/admin/providers/summary': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse(MOCK_PROVIDERS)
|
||||
return createMockResponse({
|
||||
total: MOCK_PROVIDERS.length,
|
||||
page: 1,
|
||||
page_size: MOCK_PROVIDERS.length,
|
||||
items: MOCK_PROVIDERS,
|
||||
})
|
||||
},
|
||||
|
||||
'GET /api/admin/providers': async () => {
|
||||
@@ -1387,6 +1711,33 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
return createMockResponse(MOCK_PROVIDERS)
|
||||
},
|
||||
|
||||
'GET /api/admin/pool/overview': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: [{
|
||||
provider_id: MOCK_CODEX_POOL_PROVIDER_ID,
|
||||
provider_name: MOCK_CODEX_POOL_PROVIDER.name,
|
||||
provider_type: 'codex',
|
||||
total_keys: 4,
|
||||
active_keys: 4,
|
||||
cooldown_count: 0,
|
||||
pool_enabled: true,
|
||||
provider_hot_count: 2,
|
||||
provider_desired_hot: 3,
|
||||
provider_in_flight: 1,
|
||||
provider_ema_in_flight: 0.8,
|
||||
provider_burst_pending: false,
|
||||
}]
|
||||
})
|
||||
},
|
||||
|
||||
'GET /api/admin/pool/scheduling-presets': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse([])
|
||||
},
|
||||
|
||||
'POST /api/admin/providers': async (config) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
@@ -2404,6 +2755,9 @@ registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', asy
|
||||
registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
if (params.providerId === MOCK_CODEX_POOL_PROVIDER_ID) {
|
||||
return createMockResponse(MOCK_CODEX_POOL_PROVIDER)
|
||||
}
|
||||
const provider = MOCK_PROVIDERS.find(p => p.id === params.providerId)
|
||||
if (!provider) {
|
||||
throw { response: createMockResponse({ detail: '提供商不存在' }, 404) }
|
||||
@@ -2411,6 +2765,76 @@ registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_
|
||||
return createMockResponse(provider)
|
||||
})
|
||||
|
||||
registerDynamicRoute('GET', '/api/admin/pool/:providerId/keys', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
if (params.providerId !== MOCK_CODEX_POOL_PROVIDER_ID) {
|
||||
return createMockResponse({ total: 0, page: 1, page_size: 50, keys: [] })
|
||||
}
|
||||
|
||||
const query = (config.params || {}) as Record<string, unknown>
|
||||
const search = String(query.search || '').trim().toLowerCase()
|
||||
const status = String(query.status || 'all').trim().toLowerCase()
|
||||
const sortBy = String(query.sort_by || 'imported_at').trim()
|
||||
const sortOrder = String(query.sort_order || 'desc').trim().toLowerCase()
|
||||
let keys = createMockCodexPoolKeys()
|
||||
|
||||
if (search) {
|
||||
keys = keys.filter(key => [
|
||||
key.key_name,
|
||||
key.oauth_plan_type,
|
||||
key.oauth_account_id,
|
||||
key.account_quota,
|
||||
].some(value => String(value || '').toLowerCase().includes(search)))
|
||||
}
|
||||
if (status === 'enabled') {
|
||||
keys = keys.filter(key => key.is_active)
|
||||
} else if (status === 'disabled') {
|
||||
keys = keys.filter(key => !key.is_active)
|
||||
} else if (status !== 'all') {
|
||||
keys = keys.filter(key => key.scheduling_status === status || key.scheduling_reason === status)
|
||||
}
|
||||
|
||||
keys.sort((left, right) => {
|
||||
const leftValue = String((left as Record<string, unknown>)[sortBy] ?? left.imported_at ?? '')
|
||||
const rightValue = String((right as Record<string, unknown>)[sortBy] ?? right.imported_at ?? '')
|
||||
const comparison = leftValue.localeCompare(rightValue)
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
const rawPage = Number(query.page)
|
||||
const rawPageSize = Number(query.page_size)
|
||||
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
|
||||
const pageSize = Number.isFinite(rawPageSize) && rawPageSize >= 1 ? Math.floor(rawPageSize) : 50
|
||||
const start = (page - 1) * pageSize
|
||||
return createMockResponse({
|
||||
total: keys.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keys: keys.slice(start, start + pageSize),
|
||||
})
|
||||
})
|
||||
|
||||
// Provider 模型映射预览
|
||||
registerDynamicRoute('GET', '/api/admin/providers/:providerId/mapping-preview', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const provider = MOCK_PROVIDERS.find(p => p.id === params.providerId)
|
||||
if (!provider) {
|
||||
throw { response: createMockResponse({ detail: '提供商不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse({
|
||||
provider_id: provider.id,
|
||||
provider_name: provider.name,
|
||||
keys: [],
|
||||
total_keys: 0,
|
||||
total_matches: 0,
|
||||
truncated: false,
|
||||
truncated_keys: 0,
|
||||
truncated_models: 0,
|
||||
})
|
||||
})
|
||||
|
||||
// Provider 更新
|
||||
registerDynamicRoute('PATCH', '/api/admin/providers/:providerId', async (config, params) => {
|
||||
await delay()
|
||||
@@ -2486,13 +2910,37 @@ registerDynamicRoute('DELETE', '/api/admin/endpoints/:endpointId', async (_confi
|
||||
})
|
||||
|
||||
// Provider Keys 列表
|
||||
registerDynamicRoute('GET', '/api/admin/endpoints/providers/:providerId/keys', async (_config, params) => {
|
||||
registerDynamicRoute('GET', '/api/admin/endpoints/providers/:providerId/keys', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
|
||||
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
|
||||
}
|
||||
return createMockResponse(PROVIDER_KEYS_CACHE[params.providerId])
|
||||
const keys = PROVIDER_KEYS_CACHE[params.providerId]
|
||||
const query = config.params || {}
|
||||
|
||||
// 当前详情抽屉使用 page/page_size 分页;其他调用仍使用 skip/limit 并期望裸数组。
|
||||
if (query.page !== undefined || query.page_size !== undefined) {
|
||||
const rawPage = Number(query.page)
|
||||
const rawPageSize = Number(query.page_size)
|
||||
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
|
||||
const pageSize = Number.isFinite(rawPageSize) && rawPageSize >= 1
|
||||
? Math.floor(rawPageSize)
|
||||
: 20
|
||||
const start = (page - 1) * pageSize
|
||||
return createMockResponse({
|
||||
total: keys.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keys: keys.slice(start, start + pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
const rawSkip = Number(query.skip)
|
||||
const rawLimit = Number(query.limit)
|
||||
const skip = Number.isFinite(rawSkip) && rawSkip >= 0 ? Math.floor(rawSkip) : 0
|
||||
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.floor(rawLimit) : keys.length
|
||||
return createMockResponse(keys.slice(skip, skip + limit))
|
||||
})
|
||||
|
||||
// 为 Provider 创建 Key
|
||||
@@ -2545,6 +2993,25 @@ registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/keys',
|
||||
registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/refresh-quota', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
if (params.providerId === MOCK_CODEX_POOL_PROVIDER_ID) {
|
||||
const body = JSON.parse(config.data || '{}')
|
||||
const requestedKeyIds = Array.isArray(body.key_ids)
|
||||
? body.key_ids.map((id: unknown) => String(id).trim()).filter(Boolean)
|
||||
: createMockCodexPoolKeys().map(key => key.key_id)
|
||||
const keyNames = new Map(createMockCodexPoolKeys().map(key => [key.key_id, key.key_name]))
|
||||
const results = requestedKeyIds.map((keyId: string) => ({
|
||||
key_id: keyId,
|
||||
key_name: keyNames.get(keyId) || keyId,
|
||||
status: 'success',
|
||||
metadata: { updated_at: new Date().toISOString() },
|
||||
}))
|
||||
return createMockResponse({
|
||||
success: results.length,
|
||||
failed: 0,
|
||||
total: results.length,
|
||||
results,
|
||||
})
|
||||
}
|
||||
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
|
||||
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
|
||||
}
|
||||
@@ -2684,6 +3151,20 @@ registerDynamicRoute('POST', '/api/admin/endpoints/keys/:keyId/clear-oauth-inval
|
||||
return createMockResponse({ message: 'OAuth invalid cleared (demo)', key_id: params.keyId })
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/endpoints/keys/:keyId/reset-cycle-stats', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const key = createMockCodexPoolKeys().find(item => item.key_id === params.keyId)
|
||||
const windows = key?.status_snapshot.quota.windows.filter(window => (
|
||||
window.window_minutes > 0 && !window.code.startsWith('spark_')
|
||||
)).length ?? 0
|
||||
return createMockResponse({
|
||||
message: '已重置周期统计(演示模式)',
|
||||
reset_at: Math.floor(Date.now() / 1000),
|
||||
windows,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Keys grouped by format
|
||||
mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
|
||||
@@ -3206,10 +3687,122 @@ registerDynamicRoute('DELETE', '/api/admin/users/:userId', async (_config, param
|
||||
})
|
||||
|
||||
// 用户 API Keys
|
||||
registerDynamicRoute('GET', '/api/admin/users/:userId/api-keys', async (_config, _params) => {
|
||||
registerDynamicRoute('GET', '/api/admin/users/:userId/api-keys', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse(MOCK_USER_API_KEYS)
|
||||
const apiKeys = mockManagedUserApiKeys(params.userId).map(publicMockManagedUserApiKey)
|
||||
return createMockResponse({
|
||||
api_keys: apiKeys,
|
||||
total: apiKeys.length,
|
||||
})
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/users/:userId/api-keys', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const keys = mockManagedUserApiKeys(params.userId)
|
||||
const body = mockRequestObject(config)
|
||||
const sequence = ++mockManagedUserApiKeySequence
|
||||
const fullKey = `sk-ae-demo-${params.userId.slice(0, 8)}-${sequence}`
|
||||
const key: MockManagedUserApiKey = {
|
||||
id: `managed-key-${params.userId}-${sequence}`,
|
||||
fullKey,
|
||||
key_display: `${fullKey.slice(0, 10)}...${fullKey.slice(-4)}`,
|
||||
name: typeof body.name === 'string' && body.name.trim()
|
||||
? body.name.trim()
|
||||
: `Key-${sequence}`,
|
||||
created_at: new Date().toISOString(),
|
||||
is_active: true,
|
||||
is_locked: false,
|
||||
is_standalone: false,
|
||||
feature_settings: body.feature_settings && typeof body.feature_settings === 'object'
|
||||
? body.feature_settings as Record<string, unknown>
|
||||
: null,
|
||||
rate_limit: typeof body.rate_limit === 'number' ? body.rate_limit : 0,
|
||||
concurrent_limit: typeof body.concurrent_limit === 'number'
|
||||
? body.concurrent_limit
|
||||
: null,
|
||||
ip_rules: Array.isArray(body.ip_rules)
|
||||
? body.ip_rules.filter((value): value is string => typeof value === 'string')
|
||||
: null,
|
||||
total_requests: 0,
|
||||
total_cost_usd: 0,
|
||||
force_capabilities: null,
|
||||
}
|
||||
keys.unshift(key)
|
||||
return createMockResponse({
|
||||
...publicMockManagedUserApiKey(key),
|
||||
key: fullKey,
|
||||
message: 'API Key创建成功,请妥善保存完整密钥',
|
||||
})
|
||||
})
|
||||
|
||||
registerDynamicRoute('PUT', '/api/admin/users/:userId/api-keys/:keyId', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const keys = mockManagedUserApiKeys(params.userId)
|
||||
const index = keys.findIndex(key => key.id === params.keyId)
|
||||
if (index < 0) {
|
||||
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
|
||||
}
|
||||
const body = mockRequestObject(config)
|
||||
const existing = keys[index]
|
||||
const updated: MockManagedUserApiKey = {
|
||||
...existing,
|
||||
...(typeof body.name === 'string' ? { name: body.name.trim() } : {}),
|
||||
...(typeof body.rate_limit === 'number' ? { rate_limit: body.rate_limit } : {}),
|
||||
...(typeof body.concurrent_limit === 'number' || body.concurrent_limit === null
|
||||
? { concurrent_limit: body.concurrent_limit }
|
||||
: {}),
|
||||
...(Array.isArray(body.ip_rules) || body.ip_rules === null
|
||||
? { ip_rules: body.ip_rules as string[] | null }
|
||||
: {}),
|
||||
...('feature_settings' in body
|
||||
? { feature_settings: body.feature_settings as Record<string, unknown> | null }
|
||||
: {}),
|
||||
}
|
||||
keys[index] = updated
|
||||
return createMockResponse({
|
||||
...publicMockManagedUserApiKey(updated),
|
||||
message: 'API Key更新成功',
|
||||
})
|
||||
})
|
||||
|
||||
registerDynamicRoute('DELETE', '/api/admin/users/:userId/api-keys/:keyId', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const keys = mockManagedUserApiKeys(params.userId)
|
||||
const index = keys.findIndex(key => key.id === params.keyId)
|
||||
if (index < 0) {
|
||||
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
|
||||
}
|
||||
keys.splice(index, 1)
|
||||
return createMockResponse({ message: 'API Key删除成功' })
|
||||
})
|
||||
|
||||
registerDynamicRoute('PATCH', '/api/admin/users/:userId/api-keys/:keyId/lock', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const key = mockManagedUserApiKeys(params.userId).find(key => key.id === params.keyId)
|
||||
if (!key) {
|
||||
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
|
||||
}
|
||||
key.is_locked = !key.is_locked
|
||||
return createMockResponse({
|
||||
id: key.id,
|
||||
is_locked: key.is_locked,
|
||||
message: key.is_locked ? 'API Key已锁定' : 'API Key已解锁',
|
||||
})
|
||||
})
|
||||
|
||||
registerDynamicRoute('GET', '/api/admin/users/:userId/api-keys/:keyId/full-key', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const key = mockManagedUserApiKeys(params.userId).find(key => key.id === params.keyId)
|
||||
if (!key) {
|
||||
throw { response: createMockResponse({ detail: 'API Key 不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse({ key: key.fullKey })
|
||||
})
|
||||
|
||||
// 管理员 - 用户会话列表
|
||||
@@ -3295,10 +3888,13 @@ registerDynamicRoute('DELETE', '/api/users/me/api-keys/:keyId', async (_config,
|
||||
})
|
||||
|
||||
// 使用记录详情 - /api/admin/usage/:requestId
|
||||
registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, params) => {
|
||||
registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
|
||||
const includeBodies = config.params?.include_bodies !== false
|
||||
&& config.params?.include_bodies !== 'false'
|
||||
|
||||
const records = getUsageRecords()
|
||||
const record = records.find(r => r.id === params.requestId)
|
||||
|
||||
@@ -3318,6 +3914,9 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
|
||||
// 生成模拟的请求/响应数据
|
||||
const mockRequestBody = {
|
||||
model: record.model,
|
||||
...(record.requested_reasoning_effort
|
||||
? { reasoning: { effort: record.requested_reasoning_effort } }
|
||||
: {}),
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
@@ -3328,7 +3927,27 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
|
||||
stream: record.is_stream
|
||||
}
|
||||
|
||||
const mockResponseBody = record.status === 'failed' ? {
|
||||
const mockProviderRequestBody = record.id === MOCK_CYBER_POLICY_USAGE_ID
|
||||
? {
|
||||
model: record.target_model || record.model,
|
||||
reasoning: { effort: record.reasoning_effort },
|
||||
service_tier: record.service_tier,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Help me with an authorized cybersecurity research task.'
|
||||
}
|
||||
],
|
||||
stream: record.is_stream
|
||||
}
|
||||
: {
|
||||
...mockRequestBody,
|
||||
model: record.target_model || record.model
|
||||
}
|
||||
|
||||
const mockResponseBody = record.id === MOCK_CYBER_POLICY_USAGE_ID
|
||||
? MOCK_CYBER_POLICY_ERROR_BODY
|
||||
: record.status === 'failed' ? {
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: record.error_message || 'An error occurred'
|
||||
@@ -3376,6 +3995,10 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
|
||||
api_format: record.api_format,
|
||||
model: record.model,
|
||||
target_model: record.target_model,
|
||||
requested_reasoning_effort: record.requested_reasoning_effort,
|
||||
reasoning_effort: record.reasoning_effort,
|
||||
service_tier: record.service_tier,
|
||||
actual_service_tier: record.actual_service_tier,
|
||||
tokens: {
|
||||
input: record.input_tokens,
|
||||
output: record.output_tokens,
|
||||
@@ -3406,6 +4029,7 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
|
||||
error_message: record.error_message,
|
||||
response_time_ms: record.response_time_ms,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at ?? record.created_at,
|
||||
request_headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer sk-aether-***',
|
||||
@@ -3414,7 +4038,12 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
|
||||
'Accept': 'application/json',
|
||||
'X-Request-ID': `req_${record.id}`
|
||||
},
|
||||
request_body: mockRequestBody,
|
||||
has_request_body: true,
|
||||
has_provider_request_body: true,
|
||||
has_response_body: true,
|
||||
has_client_response_body: false,
|
||||
request_body: includeBodies ? mockRequestBody : null,
|
||||
provider_request_body: includeBodies ? mockProviderRequestBody : null,
|
||||
provider_request_headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer sk-${record.provider}-***`,
|
||||
@@ -3428,7 +4057,9 @@ registerDynamicRoute('GET', '/api/admin/usage/:requestId', async (_config, param
|
||||
'X-RateLimit-Remaining': '999',
|
||||
'X-RateLimit-Reset': new Date(Date.now() + 60000).toISOString()
|
||||
},
|
||||
response_body: mockResponseBody,
|
||||
response_body: includeBodies ? mockResponseBody : null,
|
||||
client_response_body: null,
|
||||
body_load_errors: null,
|
||||
metadata: {
|
||||
client_ip: '192.168.1.100',
|
||||
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
@@ -3576,7 +4207,8 @@ registerDynamicRoute('GET', '/api/admin/monitoring/trace/:requestId', async (_co
|
||||
})
|
||||
} else if (record.status === 'failed') {
|
||||
// 失败请求:多个候选都失败
|
||||
const attemptCount = 2 + Math.floor(Math.random() * 2)
|
||||
const isCyberPolicyDemo = record.id === MOCK_CYBER_POLICY_USAGE_ID
|
||||
const attemptCount = isCyberPolicyDemo ? 1 : 2 + Math.floor(Math.random() * 2)
|
||||
|
||||
for (let i = 0; i < attemptCount; i++) {
|
||||
const attemptStarted = new Date(now.getTime() + i * 200)
|
||||
@@ -3611,7 +4243,20 @@ registerDynamicRoute('GET', '/api/admin/monitoring/trace/:requestId', async (_co
|
||||
ranking_mode: 'FixedOrder',
|
||||
priority_mode: 'Provider',
|
||||
ranking_index: i,
|
||||
priority_slot: i + 1
|
||||
priority_slot: i + 1,
|
||||
...(isCyberPolicyDemo ? {
|
||||
upstream_response: {
|
||||
source: 'upstream_response',
|
||||
status_code: 400,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': `req_${MOCK_CYBER_POLICY_USAGE_ID}`
|
||||
},
|
||||
body: MOCK_CYBER_POLICY_ERROR_BODY,
|
||||
body_ref: `usage://request/req_${MOCK_CYBER_POLICY_USAGE_ID}/response_body`,
|
||||
body_state: 'reference'
|
||||
}
|
||||
} : {})
|
||||
},
|
||||
latency_ms: attemptLatency,
|
||||
created_at: attemptStarted.toISOString(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getCodexQuotaWindowPresentation } from '../codexQuotaWindow'
|
||||
|
||||
describe('getCodexQuotaWindowPresentation', () => {
|
||||
it.each([
|
||||
[300, '5H'],
|
||||
[10_080, '周'],
|
||||
[43_200, '月'],
|
||||
[43_800, '月'],
|
||||
[44_640, '月'],
|
||||
])('labels a %i-minute window as %s', (windowMinutes, expectedLabel) => {
|
||||
expect(getCodexQuotaWindowPresentation({
|
||||
code: 'primary',
|
||||
window_minutes: windowMinutes,
|
||||
})?.label).toBe(expectedLabel)
|
||||
})
|
||||
|
||||
it('supports simultaneous 5H and weekly windows', () => {
|
||||
const windows = [
|
||||
getCodexQuotaWindowPresentation({ code: 'secondary', window_minutes: 10_080 }),
|
||||
getCodexQuotaWindowPresentation({ code: 'primary', window_minutes: 300 }),
|
||||
].filter((item): item is NonNullable<typeof item> => item != null)
|
||||
|
||||
expect(windows.sort((a, b) => a.sortOrder - b.sortOrder).map(item => item.label)).toEqual(['5H', '周'])
|
||||
})
|
||||
|
||||
it('drops zero-minute placeholder windows', () => {
|
||||
expect(getCodexQuotaWindowPresentation({
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
window_minutes: 0,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps legacy labels when old snapshots have no window duration', () => {
|
||||
expect(getCodexQuotaWindowPresentation({ code: '5h' })?.label).toBe('5H')
|
||||
expect(getCodexQuotaWindowPresentation({ code: 'weekly' })?.label).toBe('周')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatOAuthPlanType } from '../oauthPlanType'
|
||||
|
||||
describe('formatOAuthPlanType', () => {
|
||||
it('uses the compact Codex label for the usage-based business plan', () => {
|
||||
expect(formatOAuthPlanType('self_serve_business_usage_based')).toBe('Codex')
|
||||
expect(formatOAuthPlanType(' SELF_SERVE_BUSINESS_USAGE_BASED ')).toBe('Codex')
|
||||
})
|
||||
|
||||
it('keeps existing known plan labels intact', () => {
|
||||
expect(formatOAuthPlanType('plus')).toBe('Plus')
|
||||
expect(formatOAuthPlanType('team')).toBe('Team')
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,34 @@ describe('providerKeyQuota', () => {
|
||||
}, 'codex')).toBe('周剩余 90.0% | 5H剩余 80.0% | Spark5H剩余 60.0% | Spark周剩余 95.0%')
|
||||
})
|
||||
|
||||
it('uses actual Codex window durations and ignores zero placeholders', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
oauth: { code: 'valid' },
|
||||
account: { code: 'ok', blocked: false },
|
||||
quota: {
|
||||
provider_type: 'codex',
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
windows: [
|
||||
{
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
window_minutes: 0,
|
||||
remaining_ratio: 1,
|
||||
},
|
||||
{
|
||||
code: '5h',
|
||||
label: '5H',
|
||||
window_minutes: 43_800,
|
||||
remaining_ratio: 0.86,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, 'codex')).toBe('月剩余 86.0%')
|
||||
})
|
||||
|
||||
it('formats Grok account quota from structured quota windows', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
|
||||
const MINUTES_PER_HOUR = 60
|
||||
const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR
|
||||
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY
|
||||
const MIN_MONTH_MINUTES = 28 * MINUTES_PER_DAY
|
||||
const MAX_MONTH_MINUTES = 31 * MINUTES_PER_DAY
|
||||
|
||||
export interface CodexQuotaWindowPresentation {
|
||||
label: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
function formatCodexQuotaPeriod(windowMinutes: number): string {
|
||||
if (windowMinutes === 5 * MINUTES_PER_HOUR) return '5H'
|
||||
if (windowMinutes === MINUTES_PER_WEEK) return '周'
|
||||
if (windowMinutes >= MIN_MONTH_MINUTES && windowMinutes <= MAX_MONTH_MINUTES) return '月'
|
||||
|
||||
if (windowMinutes % MINUTES_PER_WEEK === 0) {
|
||||
return `${windowMinutes / MINUTES_PER_WEEK}周`
|
||||
}
|
||||
if (windowMinutes % MINUTES_PER_DAY === 0) {
|
||||
return `${windowMinutes / MINUTES_PER_DAY}天`
|
||||
}
|
||||
if (windowMinutes % MINUTES_PER_HOUR === 0) {
|
||||
return `${windowMinutes / MINUTES_PER_HOUR}H`
|
||||
}
|
||||
return `${windowMinutes}分钟`
|
||||
}
|
||||
|
||||
function getLegacyCodexQuotaPeriod(code: string, label: string): string | null {
|
||||
if (code === '5h') return '5H'
|
||||
if (code === 'weekly') return '周'
|
||||
if (code === 'monthly') return '月'
|
||||
return label || null
|
||||
}
|
||||
|
||||
export function getCodexQuotaWindowPresentation(
|
||||
window: QuotaWindowSnapshot,
|
||||
): CodexQuotaWindowPresentation | null {
|
||||
const code = String(window.code || '').trim().toLowerCase()
|
||||
const isSpark = code.startsWith('spark_')
|
||||
const baseCode = isSpark ? code.slice('spark_'.length) : code
|
||||
const rawLabel = String(window.label || '').trim().replace(/^Spark\s*/i, '')
|
||||
const hasExplicitWindowMinutes = window.window_minutes != null
|
||||
const windowMinutes = Number(window.window_minutes)
|
||||
|
||||
if (hasExplicitWindowMinutes && (!Number.isFinite(windowMinutes) || windowMinutes <= 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const period = hasExplicitWindowMinutes
|
||||
? formatCodexQuotaPeriod(windowMinutes)
|
||||
: getLegacyCodexQuotaPeriod(baseCode, rawLabel)
|
||||
if (!period) return null
|
||||
|
||||
const fallbackOrder = baseCode === '5h' ? 300 : baseCode === 'weekly' ? 10_080 : 1_000_000
|
||||
return {
|
||||
label: isSpark ? `Spark${period}` : period,
|
||||
sortOrder: (isSpark ? 10_000_000 : 0) + (hasExplicitWindowMinutes ? windowMinutes : fallbackOrder),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user