mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: 请求候选追踪添加 proxy 元数据, 修复 usage 状态回退, 优化前端轮询
- 在各 planner decision payload 中注入 proxy trace 信息 (node_id, node_name, url, source) - request_candidate 报告上下文支持 proxy 字段, extra_data 合并逻辑改为 merge 而非覆盖 - SQL/内存仓库防止 usage status 从 streaming 回退到 pending - 前端移除活跃请求完成时的全表刷新, active discovery 尊重 globalAutoRefresh 开关
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
use aether_contracts::ProxySnapshot;
|
||||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
@@ -24,6 +25,29 @@ pub(crate) struct LocalExecutionCandidateMetadataParts<'a> {
|
|||||||
pub(crate) extra_fields: Map<String, Value>,
|
pub(crate) extra_fields: Map<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_request_trace_proxy_value(
|
||||||
|
transport: Option<&GatewayProviderTransportSnapshot>,
|
||||||
|
resolved_proxy: Option<&ProxySnapshot>,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let resolved_proxy = resolved_proxy?;
|
||||||
|
let mut object = Map::new();
|
||||||
|
|
||||||
|
if let Some(node_id) = trimmed_non_empty(resolved_proxy.node_id.as_deref()) {
|
||||||
|
object.insert("node_id".to_string(), Value::String(node_id));
|
||||||
|
}
|
||||||
|
if let Some(node_name) = trimmed_non_empty(resolved_proxy.label.as_deref()) {
|
||||||
|
object.insert("node_name".to_string(), Value::String(node_name));
|
||||||
|
}
|
||||||
|
if let Some(url) = sanitize_trace_proxy_url(resolved_proxy.url.as_deref()) {
|
||||||
|
object.insert("url".to_string(), Value::String(url));
|
||||||
|
}
|
||||||
|
if let Some(source) = resolve_request_trace_proxy_source(transport, true) {
|
||||||
|
object.insert("source".to_string(), Value::String(source.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
(!object.is_empty()).then_some(Value::Object(object))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_local_execution_candidate_metadata(
|
pub(crate) fn build_local_execution_candidate_metadata(
|
||||||
parts: LocalExecutionCandidateMetadataParts<'_>,
|
parts: LocalExecutionCandidateMetadataParts<'_>,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
@@ -250,6 +274,67 @@ fn summarize_proxy_config(proxy: Option<&Value>) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_request_trace_proxy_source(
|
||||||
|
transport: Option<&GatewayProviderTransportSnapshot>,
|
||||||
|
has_resolved_proxy: bool,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
let transport = transport?;
|
||||||
|
if transport_has_explicit_proxy(transport.key.proxy.as_ref()) {
|
||||||
|
return Some("key");
|
||||||
|
}
|
||||||
|
if transport_has_explicit_proxy(transport.endpoint.proxy.as_ref()) {
|
||||||
|
return Some("endpoint");
|
||||||
|
}
|
||||||
|
if transport_has_explicit_proxy(transport.provider.proxy.as_ref()) {
|
||||||
|
return Some("provider");
|
||||||
|
}
|
||||||
|
has_resolved_proxy.then_some("system")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_has_explicit_proxy(proxy: Option<&Value>) -> bool {
|
||||||
|
let Some(object) = proxy.and_then(Value::as_object) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let enabled = object
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if !enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
object
|
||||||
|
.get("node_id")
|
||||||
|
.or_else(|| object.get("url"))
|
||||||
|
.or_else(|| object.get("proxy_url"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_trace_proxy_url(url: Option<&str>) -> Option<String> {
|
||||||
|
let raw = url.map(str::trim).filter(|value| !value.is_empty())?;
|
||||||
|
let parsed = url::Url::parse(raw).ok()?;
|
||||||
|
let scheme = parsed.scheme().trim();
|
||||||
|
let host = parsed.host_str()?.trim();
|
||||||
|
if scheme.is_empty() || host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut safe = format!("{scheme}://{host}");
|
||||||
|
if let Some(port) = parsed.port() {
|
||||||
|
safe.push(':');
|
||||||
|
safe.push_str(port.to_string().as_str());
|
||||||
|
}
|
||||||
|
Some(safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_request_transport_unsupported_reason(
|
fn resolve_request_transport_unsupported_reason(
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
client_api_format: &str,
|
client_api_format: &str,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::ai_pipeline::planner::candidate_materialization::mark_skipped_local_execution_candidate;
|
use crate::ai_pipeline::planner::candidate_materialization::mark_skipped_local_execution_candidate;
|
||||||
|
use crate::ai_pipeline::planner::candidate_metadata::build_request_trace_proxy_value;
|
||||||
use crate::ai_pipeline::planner::materialization_policy::{
|
use crate::ai_pipeline::planner::materialization_policy::{
|
||||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||||
};
|
};
|
||||||
@@ -60,6 +61,11 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
|||||||
.await;
|
.await;
|
||||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||||
let mut extra_fields = serde_json::Map::new();
|
let mut extra_fields = serde_json::Map::new();
|
||||||
|
if let Some(proxy_value) =
|
||||||
|
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||||
|
{
|
||||||
|
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||||
|
}
|
||||||
if resolved.is_kiro {
|
if resolved.is_kiro {
|
||||||
extra_fields.insert(
|
extra_fields.insert(
|
||||||
"envelope_name".to_string(),
|
"envelope_name".to_string(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::planner::candidate_metadata::build_request_trace_proxy_value;
|
||||||
use crate::ai_pipeline::planner::payload_metadata::{
|
use crate::ai_pipeline::planner::payload_metadata::{
|
||||||
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
||||||
};
|
};
|
||||||
@@ -58,6 +59,9 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
|
|||||||
.await;
|
.await;
|
||||||
let tls_profile = resolve_transport_tls_profile(&transport);
|
let tls_profile = resolve_transport_tls_profile(&transport);
|
||||||
let mut extra_fields = serde_json::Map::new();
|
let mut extra_fields = serde_json::Map::new();
|
||||||
|
if let Some(proxy_value) = build_request_trace_proxy_value(Some(&transport), proxy.as_ref()) {
|
||||||
|
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||||
|
}
|
||||||
extra_fields.insert("file_key_id".to_string(), json!(candidate.key_id));
|
extra_fields.insert("file_key_id".to_string(), json!(candidate.key_id));
|
||||||
extra_fields.insert("file_name".to_string(), json!(resolved.file_name));
|
extra_fields.insert("file_name".to_string(), json!(resolved.file_name));
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::ai_pipeline::planner::candidate_metadata::build_request_trace_proxy_value;
|
||||||
use crate::ai_pipeline::planner::payload_metadata::{
|
use crate::ai_pipeline::planner::payload_metadata::{
|
||||||
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
||||||
};
|
};
|
||||||
@@ -42,6 +43,10 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
|
|||||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
||||||
.await;
|
.await;
|
||||||
let tls_profile = resolve_transport_tls_profile(&transport);
|
let tls_profile = resolve_transport_tls_profile(&transport);
|
||||||
|
let mut extra_fields = serde_json::Map::new();
|
||||||
|
if let Some(proxy_value) = build_request_trace_proxy_value(Some(&transport), proxy.as_ref()) {
|
||||||
|
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||||
|
}
|
||||||
|
|
||||||
Some(build_local_execution_decision_response(
|
Some(build_local_execution_decision_response(
|
||||||
LocalExecutionDecisionResponseParts {
|
LocalExecutionDecisionResponseParts {
|
||||||
@@ -103,7 +108,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
|
|||||||
original_request_body: body_json,
|
original_request_body: body_json,
|
||||||
has_envelope: false,
|
has_envelope: false,
|
||||||
needs_conversion: false,
|
needs_conversion: false,
|
||||||
extra_fields: serde_json::Map::new(),
|
extra_fields,
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
auth_context: input.auth_context.clone(),
|
auth_context: input.auth_context.clone(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::ai_pipeline::planner::candidate_materialization::mark_skipped_local_execution_candidate;
|
use crate::ai_pipeline::planner::candidate_materialization::mark_skipped_local_execution_candidate;
|
||||||
|
use crate::ai_pipeline::planner::candidate_metadata::build_request_trace_proxy_value;
|
||||||
use crate::ai_pipeline::planner::materialization_policy::{
|
use crate::ai_pipeline::planner::materialization_policy::{
|
||||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||||
};
|
};
|
||||||
@@ -41,6 +42,15 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
state, parts, trace_id, body_json, input, &attempt, spec,
|
state, parts, trace_id, body_json, input, &attempt, spec,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let proxy = state
|
||||||
|
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||||
|
.await;
|
||||||
|
let mut extra_fields = serde_json::Map::new();
|
||||||
|
if let Some(proxy_value) =
|
||||||
|
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||||
|
{
|
||||||
|
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||||
|
}
|
||||||
|
|
||||||
Some(build_local_execution_decision_response(
|
Some(build_local_execution_decision_response(
|
||||||
LocalExecutionDecisionResponseParts {
|
LocalExecutionDecisionResponseParts {
|
||||||
@@ -68,9 +78,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
provider_request_body: Some(resolved.provider_request_body.clone()),
|
provider_request_body: Some(resolved.provider_request_body.clone()),
|
||||||
provider_request_body_base64: None,
|
provider_request_body_base64: None,
|
||||||
content_type: Some("application/json".to_string()),
|
content_type: Some("application/json".to_string()),
|
||||||
proxy: state
|
proxy,
|
||||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
|
||||||
.await,
|
|
||||||
tls_profile: resolve_transport_tls_profile(&resolved.transport),
|
tls_profile: resolve_transport_tls_profile(&resolved.transport),
|
||||||
timeouts: resolve_transport_execution_timeouts(&resolved.transport),
|
timeouts: resolve_transport_execution_timeouts(&resolved.transport),
|
||||||
upstream_is_stream: resolved.upstream_is_stream,
|
upstream_is_stream: resolved.upstream_is_stream,
|
||||||
@@ -99,7 +107,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
|||||||
original_request_body: body_json,
|
original_request_body: body_json,
|
||||||
has_envelope: false,
|
has_envelope: false,
|
||||||
needs_conversion: true,
|
needs_conversion: true,
|
||||||
extra_fields: serde_json::Map::new(),
|
extra_fields,
|
||||||
}),
|
}),
|
||||||
ExecutionStrategy::LocalCrossFormat,
|
ExecutionStrategy::LocalCrossFormat,
|
||||||
ConversionMode::Bidirectional,
|
ConversionMode::Bidirectional,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::ai_pipeline::planner::candidate_metadata::build_request_trace_proxy_value;
|
||||||
use crate::ai_pipeline::planner::payload_metadata::{
|
use crate::ai_pipeline::planner::payload_metadata::{
|
||||||
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
||||||
};
|
};
|
||||||
@@ -60,6 +61,12 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
|||||||
.await;
|
.await;
|
||||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||||
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
||||||
|
let mut extra_fields = serde_json::Map::new();
|
||||||
|
if let Some(proxy_value) =
|
||||||
|
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||||
|
{
|
||||||
|
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||||
|
}
|
||||||
|
|
||||||
Some(build_local_execution_decision_response(
|
Some(build_local_execution_decision_response(
|
||||||
LocalExecutionDecisionResponseParts {
|
LocalExecutionDecisionResponseParts {
|
||||||
@@ -119,7 +126,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
|||||||
resolved.conversion_mode,
|
resolved.conversion_mode,
|
||||||
crate::ai_pipeline::ConversionMode::Bidirectional
|
crate::ai_pipeline::ConversionMode::Bidirectional
|
||||||
),
|
),
|
||||||
extra_fields: serde_json::Map::new(),
|
extra_fields,
|
||||||
}),
|
}),
|
||||||
resolved.execution_strategy,
|
resolved.execution_strategy,
|
||||||
resolved.conversion_mode,
|
resolved.conversion_mode,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::ai_pipeline::planner::candidate_metadata::build_request_trace_proxy_value;
|
||||||
use crate::ai_pipeline::planner::payload_metadata::{
|
use crate::ai_pipeline::planner::payload_metadata::{
|
||||||
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
build_local_execution_decision_response, LocalExecutionDecisionResponseParts,
|
||||||
};
|
};
|
||||||
@@ -62,6 +63,11 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
|||||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||||
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
||||||
let mut extra_fields = serde_json::Map::new();
|
let mut extra_fields = serde_json::Map::new();
|
||||||
|
if let Some(proxy_value) =
|
||||||
|
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||||
|
{
|
||||||
|
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||||
|
}
|
||||||
if resolved.is_antigravity {
|
if resolved.is_antigravity {
|
||||||
extra_fields.insert("envelope_name".to_string(), json!("antigravity:v1internal"));
|
extra_fields.insert("envelope_name".to_string(), json!("antigravity:v1internal"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,6 +501,24 @@ async fn gateway_executes_openai_cli_sync_via_local_decision_gate_with_local_syn
|
|||||||
.expect("request candidate trace should read");
|
.expect("request candidate trace should read");
|
||||||
assert_eq!(stored_candidates.len(), 1);
|
assert_eq!(stored_candidates.len(), 1);
|
||||||
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Success);
|
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Success);
|
||||||
|
assert_eq!(
|
||||||
|
stored_candidates[0]
|
||||||
|
.extra_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("proxy"))
|
||||||
|
.and_then(|value| value.get("node_id"))
|
||||||
|
.and_then(serde_json::Value::as_str),
|
||||||
|
Some("proxy-node-openai-cli-local")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stored_candidates[0]
|
||||||
|
.extra_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("proxy"))
|
||||||
|
.and_then(|value| value.get("source"))
|
||||||
|
.and_then(serde_json::Value::as_str),
|
||||||
|
Some("key")
|
||||||
|
);
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -532,6 +532,13 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
|||||||
}) {
|
}) {
|
||||||
return Ok(existing.expect("existing usage should be present").clone());
|
return Ok(existing.expect("existing usage should be present").clone());
|
||||||
}
|
}
|
||||||
|
if existing.is_some_and(|existing| {
|
||||||
|
existing.billing_status == "pending"
|
||||||
|
&& existing.status == "streaming"
|
||||||
|
&& usage.status == "pending"
|
||||||
|
}) {
|
||||||
|
return Ok(existing.expect("existing usage should be present").clone());
|
||||||
|
}
|
||||||
|
|
||||||
let request_metadata = usage
|
let request_metadata = usage
|
||||||
.request_metadata
|
.request_metadata
|
||||||
@@ -1095,6 +1102,162 @@ mod tests {
|
|||||||
assert_eq!(stored.total_tokens, 10);
|
assert_eq!(stored.total_tokens, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_pending_update_does_not_regress_streaming_usage() {
|
||||||
|
let repository = InMemoryUsageReadRepository::default();
|
||||||
|
repository
|
||||||
|
.upsert(UpsertUsageRecord {
|
||||||
|
request_id: "req-streaming-1".to_string(),
|
||||||
|
user_id: Some("user-1".to_string()),
|
||||||
|
api_key_id: Some("api-key-1".to_string()),
|
||||||
|
username: None,
|
||||||
|
api_key_name: None,
|
||||||
|
provider_name: "OpenAI".to_string(),
|
||||||
|
model: "gpt-5".to_string(),
|
||||||
|
target_model: Some("gpt-5-upstream".to_string()),
|
||||||
|
provider_id: Some("provider-1".to_string()),
|
||||||
|
provider_endpoint_id: Some("endpoint-1".to_string()),
|
||||||
|
provider_api_key_id: Some("provider-key-1".to_string()),
|
||||||
|
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(true),
|
||||||
|
input_tokens: Some(10),
|
||||||
|
output_tokens: Some(2),
|
||||||
|
total_tokens: Some(12),
|
||||||
|
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: Some(0.0),
|
||||||
|
actual_total_cost_usd: Some(0.0),
|
||||||
|
status_code: Some(200),
|
||||||
|
error_message: None,
|
||||||
|
error_category: None,
|
||||||
|
response_time_ms: Some(45),
|
||||||
|
first_byte_time_ms: Some(12),
|
||||||
|
status: "streaming".to_string(),
|
||||||
|
billing_status: "pending".to_string(),
|
||||||
|
request_headers: None,
|
||||||
|
request_body: None,
|
||||||
|
request_body_ref: None,
|
||||||
|
provider_request_headers: None,
|
||||||
|
provider_request_body: None,
|
||||||
|
provider_request_body_ref: None,
|
||||||
|
response_headers: None,
|
||||||
|
response_body: None,
|
||||||
|
response_body_ref: None,
|
||||||
|
client_response_headers: None,
|
||||||
|
client_response_body: None,
|
||||||
|
client_response_body_ref: None,
|
||||||
|
candidate_id: Some("cand-1".to_string()),
|
||||||
|
candidate_index: Some(1),
|
||||||
|
key_name: Some("primary".to_string()),
|
||||||
|
planner_kind: Some("claude_cli_sync".to_string()),
|
||||||
|
route_family: Some("claude".to_string()),
|
||||||
|
route_kind: Some("cli".to_string()),
|
||||||
|
execution_path: Some("remote".to_string()),
|
||||||
|
local_execution_runtime_miss_reason: None,
|
||||||
|
request_metadata: Some(json!({
|
||||||
|
"trace_id": "trace-streaming"
|
||||||
|
})),
|
||||||
|
finalized_at_unix_secs: None,
|
||||||
|
created_at_unix_ms: Some(100),
|
||||||
|
updated_at_unix_secs: 101,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("streaming usage should upsert");
|
||||||
|
|
||||||
|
repository
|
||||||
|
.upsert(UpsertUsageRecord {
|
||||||
|
request_id: "req-streaming-1".to_string(),
|
||||||
|
user_id: Some("user-1".to_string()),
|
||||||
|
api_key_id: Some("api-key-1".to_string()),
|
||||||
|
username: None,
|
||||||
|
api_key_name: None,
|
||||||
|
provider_name: "OpenAI".to_string(),
|
||||||
|
model: "gpt-5".to_string(),
|
||||||
|
target_model: None,
|
||||||
|
provider_id: Some("provider-1".to_string()),
|
||||||
|
provider_endpoint_id: Some("endpoint-1".to_string()),
|
||||||
|
provider_api_key_id: Some("provider-key-1".to_string()),
|
||||||
|
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(true),
|
||||||
|
input_tokens: None,
|
||||||
|
output_tokens: None,
|
||||||
|
total_tokens: None,
|
||||||
|
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: None,
|
||||||
|
error_message: None,
|
||||||
|
error_category: None,
|
||||||
|
response_time_ms: None,
|
||||||
|
first_byte_time_ms: None,
|
||||||
|
status: "pending".to_string(),
|
||||||
|
billing_status: "pending".to_string(),
|
||||||
|
request_headers: None,
|
||||||
|
request_body: None,
|
||||||
|
request_body_ref: None,
|
||||||
|
provider_request_headers: None,
|
||||||
|
provider_request_body: None,
|
||||||
|
provider_request_body_ref: None,
|
||||||
|
response_headers: None,
|
||||||
|
response_body: None,
|
||||||
|
response_body_ref: None,
|
||||||
|
client_response_headers: None,
|
||||||
|
client_response_body: None,
|
||||||
|
client_response_body_ref: 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: None,
|
||||||
|
finalized_at_unix_secs: None,
|
||||||
|
created_at_unix_ms: Some(100),
|
||||||
|
updated_at_unix_secs: 102,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("stale pending usage should upsert");
|
||||||
|
|
||||||
|
let stored = repository
|
||||||
|
.find_by_request_id("req-streaming-1")
|
||||||
|
.await
|
||||||
|
.expect("usage lookup should succeed")
|
||||||
|
.expect("usage should exist");
|
||||||
|
assert_eq!(stored.status, "streaming");
|
||||||
|
assert_eq!(stored.status_code, Some(200));
|
||||||
|
assert_eq!(stored.first_byte_time_ms, Some(12));
|
||||||
|
assert_eq!(stored.response_time_ms, Some(45));
|
||||||
|
assert_eq!(stored.target_model.as_deref(), Some("gpt-5-upstream"));
|
||||||
|
assert_eq!(stored.total_tokens, 12);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn seed_hydrates_legacy_body_ref_metadata_into_typed_fields() {
|
async fn seed_hydrates_legacy_body_ref_metadata_into_typed_fields() {
|
||||||
let repository = InMemoryUsageReadRepository::seed(vec![StoredRequestUsageAudit {
|
let repository = InMemoryUsageReadRepository::seed(vec![StoredRequestUsageAudit {
|
||||||
|
|||||||
@@ -870,20 +870,26 @@ DO UPDATE SET
|
|||||||
total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.total_cost_usd, "usage".total_cost_usd) ELSE "usage".total_cost_usd END,
|
total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.total_cost_usd, "usage".total_cost_usd) ELSE "usage".total_cost_usd END,
|
||||||
actual_total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.actual_total_cost_usd, "usage".actual_total_cost_usd) ELSE "usage".actual_total_cost_usd END,
|
actual_total_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.actual_total_cost_usd, "usage".actual_total_cost_usd) ELSE "usage".actual_total_cost_usd END,
|
||||||
status_code = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
status_code = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".status_code
|
||||||
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') AND EXCLUDED.status_code IS NULL THEN NULL
|
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') AND EXCLUDED.status_code IS NULL THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.status_code, "usage".status_code)
|
ELSE COALESCE(EXCLUDED.status_code, "usage".status_code)
|
||||||
END ELSE "usage".status_code END,
|
END ELSE "usage".status_code END,
|
||||||
error_message = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
error_message = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".error_message
|
||||||
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_message
|
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_message
|
||||||
ELSE COALESCE(EXCLUDED.error_message, "usage".error_message)
|
ELSE COALESCE(EXCLUDED.error_message, "usage".error_message)
|
||||||
END ELSE "usage".error_message END,
|
END ELSE "usage".error_message END,
|
||||||
error_category = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
error_category = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".error_category
|
||||||
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_category
|
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_category
|
||||||
ELSE COALESCE(EXCLUDED.error_category, "usage".error_category)
|
ELSE COALESCE(EXCLUDED.error_category, "usage".error_category)
|
||||||
END ELSE "usage".error_category END,
|
END ELSE "usage".error_category END,
|
||||||
response_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.response_time_ms, "usage".response_time_ms) ELSE "usage".response_time_ms END,
|
response_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.response_time_ms, "usage".response_time_ms) ELSE "usage".response_time_ms END,
|
||||||
first_byte_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.first_byte_time_ms, "usage".first_byte_time_ms) ELSE "usage".first_byte_time_ms END,
|
first_byte_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.first_byte_time_ms, "usage".first_byte_time_ms) ELSE "usage".first_byte_time_ms END,
|
||||||
status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.status ELSE "usage".status END,
|
status = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".status
|
||||||
|
ELSE EXCLUDED.status
|
||||||
|
END ELSE "usage".status END,
|
||||||
billing_status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.billing_status ELSE "usage".billing_status END,
|
billing_status = CASE WHEN "usage".billing_status = 'pending' THEN EXCLUDED.billing_status ELSE "usage".billing_status END,
|
||||||
request_headers = NULL,
|
request_headers = NULL,
|
||||||
request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
request_body = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
@@ -3213,6 +3219,19 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_sql_does_not_allow_streaming_to_regress_back_to_pending() {
|
||||||
|
assert!(super::UPSERT_SQL.contains(
|
||||||
|
"WHEN \"usage\".status = 'streaming' AND EXCLUDED.status = 'pending' THEN \"usage\".status_code"
|
||||||
|
));
|
||||||
|
assert!(super::UPSERT_SQL.contains(
|
||||||
|
"WHEN \"usage\".status = 'streaming' AND EXCLUDED.status = 'pending' THEN \"usage\".error_message"
|
||||||
|
));
|
||||||
|
assert!(super::UPSERT_SQL.contains(
|
||||||
|
"WHEN \"usage\".status = 'streaming' AND EXCLUDED.status = 'pending' THEN \"usage\".status"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_sql_recovers_void_failures_before_upsert_and_settlement() {
|
fn usage_sql_recovers_void_failures_before_upsert_and_settlement() {
|
||||||
assert!(super::RESET_STALE_VOID_USAGE_SQL.contains("UPDATE \"usage\""));
|
assert!(super::RESET_STALE_VOID_USAGE_SQL.contains("UPDATE \"usage\""));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use aether_data_contracts::repository::candidates::{
|
|||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct SchedulerRequestCandidateReportContext {
|
pub struct SchedulerRequestCandidateReportContext {
|
||||||
pub request_id: Option<String>,
|
pub request_id: Option<String>,
|
||||||
pub candidate_id: Option<String>,
|
pub candidate_id: Option<String>,
|
||||||
@@ -17,6 +17,7 @@ pub struct SchedulerRequestCandidateReportContext {
|
|||||||
pub key_id: Option<String>,
|
pub key_id: Option<String>,
|
||||||
pub client_api_format: Option<String>,
|
pub client_api_format: Option<String>,
|
||||||
pub provider_api_format: Option<String>,
|
pub provider_api_format: Option<String>,
|
||||||
|
pub proxy: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -109,6 +110,10 @@ pub fn parse_request_candidate_report_context(
|
|||||||
key_id: string_field(report_context, "key_id"),
|
key_id: string_field(report_context, "key_id"),
|
||||||
client_api_format: string_field(report_context, "client_api_format"),
|
client_api_format: string_field(report_context, "client_api_format"),
|
||||||
provider_api_format: string_field(report_context, "provider_api_format"),
|
provider_api_format: string_field(report_context, "provider_api_format"),
|
||||||
|
proxy: report_context
|
||||||
|
.get("proxy")
|
||||||
|
.cloned()
|
||||||
|
.filter(|value| !value.is_null()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +169,12 @@ pub fn resolve_report_request_candidate_slot(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|candidate| candidate.key_id.clone())
|
.and_then(|candidate| candidate.key_id.clone())
|
||||||
.or(metadata.key_id),
|
.or(metadata.key_id),
|
||||||
extra_data: matched_candidate
|
extra_data: merge_request_candidate_extra_data(
|
||||||
.as_ref()
|
matched_candidate
|
||||||
.and_then(|candidate| candidate.extra_data.clone())
|
.as_ref()
|
||||||
.or(synthesized_extra_data),
|
.and_then(|candidate| candidate.extra_data.clone()),
|
||||||
|
synthesized_extra_data,
|
||||||
|
),
|
||||||
created_at_unix_ms,
|
created_at_unix_ms,
|
||||||
started_at_unix_ms: matched_candidate
|
started_at_unix_ms: matched_candidate
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -475,9 +482,28 @@ fn build_report_candidate_extra_data(
|
|||||||
Value::String(provider_api_format),
|
Value::String(provider_api_format),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(proxy) = metadata.proxy.clone() {
|
||||||
|
extra_data.insert("proxy".to_string(), proxy);
|
||||||
|
}
|
||||||
(!extra_data.is_empty()).then_some(Value::Object(extra_data))
|
(!extra_data.is_empty()).then_some(Value::Object(extra_data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_request_candidate_extra_data(
|
||||||
|
existing: Option<Value>,
|
||||||
|
overlay: Option<Value>,
|
||||||
|
) -> Option<Value> {
|
||||||
|
match (existing, overlay) {
|
||||||
|
(Some(Value::Object(mut existing_object)), Some(Value::Object(overlay_object))) => {
|
||||||
|
existing_object.extend(overlay_object);
|
||||||
|
Some(Value::Object(existing_object))
|
||||||
|
}
|
||||||
|
(Some(existing), None) => Some(existing),
|
||||||
|
(None, Some(overlay)) => Some(overlay),
|
||||||
|
(Some(existing), Some(_overlay)) => Some(existing),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use aether_contracts::{
|
use aether_contracts::{
|
||||||
@@ -581,6 +607,60 @@ mod tests {
|
|||||||
assert_eq!(slot.request_id, "req-1");
|
assert_eq!(slot.request_id, "req-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_proxy_trace_info_into_existing_candidate_extra_data() {
|
||||||
|
let mut existing = sample_candidate("cand-1", 1, 0);
|
||||||
|
existing.extra_data = Some(json!({
|
||||||
|
"provider_name": "Codex"
|
||||||
|
}));
|
||||||
|
|
||||||
|
let metadata = parse_request_candidate_report_context(Some(&json!({
|
||||||
|
"request_id": "req-1",
|
||||||
|
"candidate_index": 1,
|
||||||
|
"retry_index": 0,
|
||||||
|
"provider_id": "provider-1",
|
||||||
|
"endpoint_id": "endpoint-1",
|
||||||
|
"key_id": "catalog-key-1",
|
||||||
|
"client_api_format": "openai:chat",
|
||||||
|
"provider_api_format": "openai:cli",
|
||||||
|
"proxy": {
|
||||||
|
"node_id": "proxy-node-1",
|
||||||
|
"node_name": "edge-1",
|
||||||
|
"source": "provider"
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
.expect("metadata");
|
||||||
|
|
||||||
|
let slot = resolve_report_request_candidate_slot(
|
||||||
|
&[existing],
|
||||||
|
metadata,
|
||||||
|
123,
|
||||||
|
"generated-1".to_string(),
|
||||||
|
)
|
||||||
|
.expect("slot");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
slot.extra_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("provider_name")),
|
||||||
|
Some(&json!("Codex"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slot.extra_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("proxy"))
|
||||||
|
.and_then(|value| value.get("node_id")),
|
||||||
|
Some(&json!("proxy-node-1"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slot.extra_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("proxy"))
|
||||||
|
.and_then(|value| value.get("source")),
|
||||||
|
Some(&json!("provider"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_error_details_from_execution_error_or_body_json() {
|
fn resolves_error_details_from_execution_error_or_body_json() {
|
||||||
let error = ExecutionError {
|
let error = ExecutionError {
|
||||||
|
|||||||
@@ -362,17 +362,11 @@ async function pollActiveRequests() {
|
|||||||
try {
|
try {
|
||||||
const { requests } = await loadActiveRequestUpdates(activeRequestIds.value)
|
const { requests } = await loadActiveRequestUpdates(activeRequestIds.value)
|
||||||
|
|
||||||
let shouldRefresh = false
|
|
||||||
|
|
||||||
const recordMap = new Map(currentRecords.value.map(record => [record.id, record]))
|
const recordMap = new Map(currentRecords.value.map(record => [record.id, record]))
|
||||||
|
|
||||||
for (const update of requests) {
|
for (const update of requests) {
|
||||||
const record = recordMap.get(update.id)
|
const record = recordMap.get(update.id)
|
||||||
if (!record) {
|
if (!record) continue
|
||||||
// 后端返回了未知的活跃请求,触发刷新以获取完整数据
|
|
||||||
shouldRefresh = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态只允许单向推进,避免异步响应回退(pending -> streaming -> completed/failed/cancelled)
|
// 状态只允许单向推进,避免异步响应回退(pending -> streaming -> completed/failed/cancelled)
|
||||||
const statusPriority: Record<string, number> = {
|
const statusPriority: Record<string, number> = {
|
||||||
@@ -389,10 +383,6 @@ async function pollActiveRequests() {
|
|||||||
if (shouldApply && record.status !== update.status) {
|
if (shouldApply && record.status !== update.status) {
|
||||||
record.status = update.status
|
record.status = update.status
|
||||||
}
|
}
|
||||||
if (shouldApply && ['completed', 'failed', 'cancelled'].includes(update.status)) {
|
|
||||||
shouldRefresh = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldApply) {
|
if (shouldApply) {
|
||||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||||
record.input_tokens = update.input_tokens
|
record.input_tokens = update.input_tokens
|
||||||
@@ -430,9 +420,8 @@ async function pollActiveRequests() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRefresh) {
|
// 不再因活跃请求完成而全表刷新,字段已在上方就地更新
|
||||||
await refreshData()
|
// 未知请求(shouldRefresh 由 !record 触发)理论上不应出现在已知 ID 轮询中,忽略即可
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('轮询活跃请求状态失败:', error)
|
log.error('轮询活跃请求状态失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -484,6 +473,7 @@ function scheduleNextAutoRefresh() {
|
|||||||
function scheduleNextActiveDiscovery() {
|
function scheduleNextActiveDiscovery() {
|
||||||
if (activeDiscoveryTimer) return
|
if (activeDiscoveryTimer) return
|
||||||
if (!isPageVisible.value) return
|
if (!isPageVisible.value) return
|
||||||
|
if (!globalAutoRefresh.value) return
|
||||||
const interval = hasActiveRequests.value || discoveredActiveRequestIds.size > 0
|
const interval = hasActiveRequests.value || discoveredActiveRequestIds.size > 0
|
||||||
? ACTIVE_DISCOVERY_HOT_INTERVAL
|
? ACTIVE_DISCOVERY_HOT_INTERVAL
|
||||||
: ACTIVE_DISCOVERY_IDLE_INTERVAL
|
: ACTIVE_DISCOVERY_IDLE_INTERVAL
|
||||||
@@ -502,6 +492,7 @@ function startAutoRefresh() {
|
|||||||
|
|
||||||
function startActiveDiscovery() {
|
function startActiveDiscovery() {
|
||||||
if (!isPageVisible.value) return
|
if (!isPageVisible.value) return
|
||||||
|
if (!globalAutoRefresh.value) return
|
||||||
if (activeDiscoveryTimer || activeDiscoveryInFlight) return
|
if (activeDiscoveryTimer || activeDiscoveryInFlight) return
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await discoverActiveRequests()
|
await discoverActiveRequests()
|
||||||
|
|||||||
Reference in New Issue
Block a user