mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8abac080a2 | ||
|
|
37cd059531 | ||
|
|
c46910df22 |
@@ -38,13 +38,6 @@ impl TimeRange {
|
||||
}
|
||||
}
|
||||
|
||||
/// 观测记录的稳定键集游标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObservabilityCursor {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub stable_id: String,
|
||||
}
|
||||
|
||||
/// 请求结果状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RequestOutcome {
|
||||
@@ -130,9 +123,8 @@ pub struct UsageFilter {
|
||||
pub struct UsageQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: UsageFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub include_total: bool,
|
||||
}
|
||||
|
||||
/// 运维错误过滤条件。
|
||||
@@ -158,9 +150,8 @@ pub struct OpsErrorFilter {
|
||||
pub struct OpsErrorQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: OpsErrorFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub include_total: bool,
|
||||
}
|
||||
|
||||
/// 用量诊断维度。
|
||||
@@ -667,8 +658,9 @@ pub struct UsageRecord {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsagePage {
|
||||
pub items: Vec<UsageListRecord>,
|
||||
pub total: Option<u64>,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// 请求中的一次上游尝试或运维事件。
|
||||
@@ -795,8 +787,9 @@ pub struct OpsError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsErrorPage {
|
||||
pub items: Vec<OpsError>,
|
||||
pub total: Option<u64>,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// 仪表盘趋势指标。
|
||||
|
||||
@@ -675,13 +675,14 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
|
||||
Ok(UsagePage {
|
||||
items,
|
||||
total: Some(total),
|
||||
next_cursor: None,
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -783,9 +784,8 @@ fn usage_query(now: DateTime<Utc>) -> UsageQuery {
|
||||
UsageQuery {
|
||||
range: observation_range(now),
|
||||
filter: UsageFilter::default(),
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(50).expect("page size"),
|
||||
include_total: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -515,18 +515,13 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usage_page_view(
|
||||
page: domain::UsagePage,
|
||||
) -> Result<PageData<UsageListRecordView>, WireValidationError> {
|
||||
Ok(PageData {
|
||||
pub(crate) fn usage_page_view(page: domain::UsagePage) -> PageData<UsageListRecordView> {
|
||||
PageData {
|
||||
items: page.items.into_iter().map(usage_list_record_view).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
next_cursor: page
|
||||
.next_cursor
|
||||
.as_ref()
|
||||
.map(encode_observability_cursor)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
@@ -566,18 +561,13 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ops_page_view(
|
||||
page: domain::OpsErrorPage,
|
||||
) -> Result<PageData<OpsErrorView>, WireValidationError> {
|
||||
Ok(PageData {
|
||||
pub(crate) fn ops_page_view(page: domain::OpsErrorPage) -> PageData<OpsErrorView> {
|
||||
PageData {
|
||||
items: page.items.into_iter().map(ops_error_view).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
next_cursor: page
|
||||
.next_cursor
|
||||
.as_ref()
|
||||
.map(encode_observability_cursor)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
/// 观测列表默认页大小。
|
||||
pub const DEFAULT_PAGE_SIZE: u16 = 50;
|
||||
/// 观测列表允许的最大页大小。
|
||||
pub const MAX_PAGE_SIZE: u16 = 100;
|
||||
/// 游标编码在 HTTP 层解码前允许的最大长度。
|
||||
pub const MAX_CURSOR_BYTES: usize = 1024;
|
||||
|
||||
/// Dashboard 查询参数。
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
@@ -31,8 +27,8 @@ impl DashboardQuery {
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UsageQuery {
|
||||
pub current_page: Option<u32>,
|
||||
pub page_size: Option<u16>,
|
||||
pub cursor: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub outcome: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
@@ -52,18 +48,17 @@ pub struct UsageQuery {
|
||||
}
|
||||
|
||||
impl UsageQuery {
|
||||
/// 校验分页字段,不改变仓储层的分页 owner。
|
||||
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
|
||||
/// 校验 Element Plus 风格的页码分页字段。
|
||||
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
|
||||
let current_page = self.current_page.unwrap_or(1);
|
||||
if current_page == 0 {
|
||||
return Err(WireValidationError::new("currentPage"));
|
||||
}
|
||||
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||
if page_size == 0 || page_size > MAX_PAGE_SIZE {
|
||||
return Err(WireValidationError::new("pageSize"));
|
||||
}
|
||||
Ok(page_size)
|
||||
}
|
||||
|
||||
/// 校验游标的 wire 边界;编码和排序语义由应用层负责。
|
||||
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
|
||||
validate_cursor(self.cursor.as_deref())
|
||||
Ok((current_page, page_size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +100,8 @@ impl DiagnosticsQuery {
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct OpsQuery {
|
||||
pub current_page: Option<u32>,
|
||||
pub page_size: Option<u16>,
|
||||
pub cursor: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
@@ -128,18 +123,17 @@ pub struct OpsQuery {
|
||||
}
|
||||
|
||||
impl OpsQuery {
|
||||
/// 校验分页字段。
|
||||
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
|
||||
/// 校验 Element Plus 风格的页码分页字段。
|
||||
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
|
||||
let current_page = self.current_page.unwrap_or(1);
|
||||
if current_page == 0 {
|
||||
return Err(WireValidationError::new("currentPage"));
|
||||
}
|
||||
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||
if page_size == 0 || page_size > MAX_PAGE_SIZE {
|
||||
return Err(WireValidationError::new("pageSize"));
|
||||
}
|
||||
Ok(page_size)
|
||||
}
|
||||
|
||||
/// 校验游标的 wire 边界。
|
||||
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
|
||||
validate_cursor(self.cursor.as_deref())
|
||||
Ok((current_page, page_size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,13 +242,6 @@ pub(crate) fn require_text(value: &str, field: &'static str) -> Result<(), WireV
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_cursor(value: Option<&str>) -> Result<(), WireValidationError> {
|
||||
if value.is_some_and(|value| value.is_empty() || value.len() > MAX_CURSOR_BYTES) {
|
||||
return Err(WireValidationError::new("cursor"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn trimmed(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
@@ -336,22 +323,19 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, Wi
|
||||
}
|
||||
|
||||
pub(crate) fn usage_command(query: &UsageQuery) -> Result<domain::UsageQuery, WireValidationError> {
|
||||
let page_size = query.validate_page_size()?;
|
||||
query.validate_cursor()?;
|
||||
let (current_page, page_size) = query.validate_pagination()?;
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
Ok(domain::UsageQuery {
|
||||
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
|
||||
filter: usage_filter(query)?,
|
||||
cursor: decode_observability_cursor(query.cursor.as_deref())?,
|
||||
current_page,
|
||||
page_size: page_size_value,
|
||||
include_total: query.cursor.is_none(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, WireValidationError> {
|
||||
let page_size = query.validate_page_size()?;
|
||||
query.validate_cursor()?;
|
||||
let (current_page, page_size) = query.validate_pagination()?;
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
let status_code = parse_status(
|
||||
@@ -377,47 +361,11 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
|
||||
status_code,
|
||||
search: non_empty(query.search.clone()),
|
||||
},
|
||||
cursor: decode_observability_cursor(query.cursor.as_deref())?,
|
||||
current_page,
|
||||
page_size: page_size_value,
|
||||
include_total: query.cursor.is_none(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decode_observability_cursor(
|
||||
value: Option<&str>,
|
||||
) -> Result<Option<domain::ObservabilityCursor>, WireValidationError> {
|
||||
value
|
||||
.map(|encoded| {
|
||||
if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES {
|
||||
return Err(WireValidationError::new("cursor"));
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.map_err(|_| WireValidationError::new("cursor"))?;
|
||||
let wire: CursorWire =
|
||||
serde_json::from_slice(&bytes).map_err(|_| WireValidationError::new("cursor"))?;
|
||||
if wire.stable_id.trim().is_empty() {
|
||||
return Err(WireValidationError::new("cursor"));
|
||||
}
|
||||
Ok(domain::ObservabilityCursor {
|
||||
observed_at: wire.observed_at,
|
||||
stable_id: wire.stable_id,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn encode_observability_cursor(
|
||||
cursor: &domain::ObservabilityCursor,
|
||||
) -> Result<String, WireValidationError> {
|
||||
let bytes = serde_json::to_vec(&CursorWire {
|
||||
observed_at: cursor.observed_at,
|
||||
stable_id: cursor.stable_id.clone(),
|
||||
})
|
||||
.map_err(|_| WireValidationError::new("cursor"))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn non_empty(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_owned())
|
||||
|
||||
@@ -91,7 +91,7 @@ where
|
||||
.usage_records(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = usage_page_view(result).map_err(|_| AdminError::internal())?;
|
||||
let data = usage_page_view(result);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -207,6 +207,6 @@ where
|
||||
.ops_errors(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = ops_page_view(result).map_err(|_| AdminError::internal())?;
|
||||
let data = ops_page_view(result);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 观测列表游标的稳定 wire 形状。
|
||||
#[derive(Clone, Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CursorWire {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub stable_id: String,
|
||||
}
|
||||
|
||||
/// 观测列表响应数据。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageData<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: Option<u64>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Token 详情展示。
|
||||
|
||||
@@ -858,9 +858,10 @@ impl ObservabilityStore for UnusedStore {
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
Ok(UsagePage {
|
||||
total: query.include_total.then_some(items.len() as u64),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total: items.len() as u64,
|
||||
items,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -888,9 +889,10 @@ impl ObservabilityStore for UnusedStore {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
let items = self.ops_errors.lock().expect("ops errors").clone();
|
||||
Ok(OpsErrorPage {
|
||||
total: query.include_total.then_some(items.len() as u64),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total: items.len() as u64,
|
||||
items,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,13 @@ fn dashboard_query_should_reject_unknown_trend_kind() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_bound_page_size_and_cursor() {
|
||||
fn usage_query_should_accept_element_plus_pagination() {
|
||||
let query: UsageQuery = serde_json::from_value(json!({
|
||||
"pageSize": 100,
|
||||
"cursor": "opaque"
|
||||
"currentPage": 129,
|
||||
"pageSize": 10
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(query.validate_page_size().unwrap(), 100);
|
||||
assert!(query.validate_cursor().is_ok());
|
||||
assert_eq!(query.validate_pagination().unwrap(), (129, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -35,14 +34,19 @@ fn usage_query_should_reject_removed_total_toggle() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_removed_offset_page_contract() {
|
||||
fn usage_query_should_reject_removed_cursor_contract() {
|
||||
assert!(serde_json::from_value::<UsageQuery>(json!({"cursor": "opaque"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_nonstandard_page_name() {
|
||||
assert!(serde_json::from_value::<UsageQuery>(json!({"page": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ops_query_should_reject_page_size_above_terminal_limit() {
|
||||
let query: OpsQuery = serde_json::from_value(json!({"pageSize": 101})).unwrap();
|
||||
assert_eq!(query.validate_page_size().unwrap_err().field(), "pageSize");
|
||||
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
use gateway_admin::model::observability::DesktopReleaseStatus;
|
||||
use gateway_api::admin::observability::{
|
||||
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
|
||||
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
|
||||
DashboardWireProfileView, DashboardWireTargetView, PageData, TokenDetailsView, TrendData,
|
||||
TrendKind, TrendPointView, TrendSummaryView,
|
||||
BillingView, CostCoverageView, DashboardAccountRequestBucketView, DashboardAccountUsageView,
|
||||
DashboardDesktopReleaseStatusView, DashboardWireAttributeView, DashboardWireProfileView,
|
||||
DashboardWireTargetView, PageData, TokenDetailsView, TrendData, TrendKind, TrendPointView,
|
||||
TrendSummaryView,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -14,12 +14,15 @@ use serde_json::json;
|
||||
fn usage_page_should_keep_terminal_camel_case_shape() {
|
||||
let data = PageData {
|
||||
items: vec![json!({"id": "request_1"})],
|
||||
total: Some(1),
|
||||
next_cursor: Some("cursor".to_owned()),
|
||||
current_page: 129,
|
||||
page_size: 10,
|
||||
total: 1287,
|
||||
};
|
||||
let value = serde_json::to_value(data).unwrap();
|
||||
assert_eq!(value["total"], 1);
|
||||
assert_eq!(value["nextCursor"], "cursor");
|
||||
assert_eq!(value["currentPage"], 129);
|
||||
assert_eq!(value["pageSize"], 10);
|
||||
assert_eq!(value["total"], 1287);
|
||||
assert!(value.get("nextCursor").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,11 +157,7 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
|
||||
image_output_tokens_display: "-".to_owned(),
|
||||
total_tokens_display: "3".to_owned(),
|
||||
};
|
||||
let cursor = CursorWire {
|
||||
observed_at: Utc.timestamp_opt(0, 0).single().unwrap(),
|
||||
stable_id: "request_1".to_owned(),
|
||||
};
|
||||
let value = serde_json::to_value((&coverage, &token_details, &cursor)).unwrap();
|
||||
let value = serde_json::to_value((&coverage, &token_details)).unwrap();
|
||||
assert!(value.to_string().contains("known"));
|
||||
assert!(!value.to_string().contains("secret"));
|
||||
}
|
||||
|
||||
@@ -125,9 +125,8 @@ impl ObservabilityRepository for PgObservabilityRepository {
|
||||
outcome: Some("succeeded".to_owned()),
|
||||
..UsageRecordFilter::default()
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10)?,
|
||||
include_total: false,
|
||||
};
|
||||
// 每条 SQL 独立取一个全局观测槽位,避免整包预留造成队头阻塞。
|
||||
let (totals, (provider_accounts, _)) = futures::try_join!(
|
||||
@@ -148,7 +147,7 @@ impl ObservabilityRepository for PgObservabilityRepository {
|
||||
),
|
||||
self.query_budget
|
||||
.run("load dashboard recent requests", async {
|
||||
Ok(list_usage_records(&self.pool, recent_query).await?.items)
|
||||
list_usage_record_items(&self.pool, &recent_query).await
|
||||
}),
|
||||
)?;
|
||||
Ok(DashboardObservation {
|
||||
|
||||
@@ -30,22 +30,15 @@ pub(crate) fn store_request_outcome(outcome: admin_observability::RequestOutcome
|
||||
outcome.as_str().to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn store_cursor(
|
||||
cursor: admin_observability::ObservabilityCursor,
|
||||
) -> AdminStoreResult<ObservabilityCursor> {
|
||||
ObservabilityCursor::new(cursor.observed_at, cursor.stable_id).map_err(observability_error)
|
||||
}
|
||||
|
||||
pub(crate) fn store_usage_query(
|
||||
query: admin_observability::UsageQuery,
|
||||
) -> AdminStoreResult<UsageRecordQuery> {
|
||||
Ok(UsageRecordQuery {
|
||||
range: store_range(query.range)?,
|
||||
filter: store_usage_filter(query.filter),
|
||||
cursor: query.cursor.map(store_cursor).transpose()?,
|
||||
current_page: query.current_page,
|
||||
page_size: ObservabilityPageSize::new(query.page_size.get())
|
||||
.map_err(observability_error)?,
|
||||
include_total: query.include_total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -75,10 +68,9 @@ pub(crate) fn store_ops_error_query(
|
||||
Ok(OpsErrorQuery {
|
||||
range: store_range(query.range)?,
|
||||
filter: store_ops_error_filter(query.filter),
|
||||
cursor: query.cursor.map(store_cursor).transpose()?,
|
||||
current_page: query.current_page,
|
||||
page_size: ObservabilityPageSize::new(query.page_size.get())
|
||||
.map_err(observability_error)?,
|
||||
include_total: query.include_total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -409,20 +401,12 @@ pub(crate) fn admin_usage_page(
|
||||
.into_iter()
|
||||
.map(admin_usage_list_record)
|
||||
.collect::<AdminStoreResult<_>>()?,
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
next_cursor: page.next_cursor.map(admin_cursor),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn admin_cursor(
|
||||
cursor: ObservabilityCursor,
|
||||
) -> admin_observability::ObservabilityCursor {
|
||||
admin_observability::ObservabilityCursor {
|
||||
observed_at: cursor.observed_at,
|
||||
stable_id: cursor.stable_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn admin_usage_list_record(
|
||||
record: UsageListRecord,
|
||||
) -> AdminStoreResult<admin_observability::UsageListRecord> {
|
||||
@@ -701,8 +685,9 @@ pub(crate) fn admin_ops_error_page(
|
||||
) -> AdminStoreResult<admin_observability::OpsErrorPage> {
|
||||
Ok(admin_observability::OpsErrorPage {
|
||||
items: page.items.into_iter().map(admin_ops_error).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
next_cursor: page.next_cursor.map(admin_cursor),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,21 +43,17 @@ impl ObservabilityPageSize {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObservabilityCursor {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub stable_id: String,
|
||||
}
|
||||
|
||||
impl ObservabilityCursor {
|
||||
pub fn new(observed_at: DateTime<Utc>, stable_id: impl Into<String>) -> StoreResult<Self> {
|
||||
let stable_id = stable_id.into();
|
||||
validate_text(&stable_id, MAX_FILTER_BYTES, "cursor ID")?;
|
||||
Ok(Self {
|
||||
observed_at,
|
||||
stable_id,
|
||||
})
|
||||
}
|
||||
pub(crate) fn observability_page_offset(
|
||||
current_page: u32,
|
||||
page_size: ObservabilityPageSize,
|
||||
) -> StoreResult<i64> {
|
||||
let page_index = current_page
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| invalid("current page must be positive"))?;
|
||||
u64::from(page_index)
|
||||
.checked_mul(u64::from(page_size.get()))
|
||||
.and_then(|offset| i64::try_from(offset).ok())
|
||||
.ok_or_else(|| invalid("page offset is too large"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
@@ -119,9 +115,8 @@ impl UsageRecordFilter {
|
||||
pub struct UsageRecordQuery {
|
||||
pub range: ObservabilityRange,
|
||||
pub filter: UsageRecordFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: ObservabilityPageSize,
|
||||
pub include_total: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
@@ -183,9 +178,8 @@ impl OpsErrorFilter {
|
||||
pub struct OpsErrorQuery {
|
||||
pub range: ObservabilityRange,
|
||||
pub filter: OpsErrorFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: ObservabilityPageSize,
|
||||
pub include_total: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -609,8 +603,9 @@ pub struct UsageRecord {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageRecordPage {
|
||||
pub items: Vec<UsageListRecord>,
|
||||
pub total: Option<u64>,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -723,8 +718,9 @@ pub struct OpsErrorRecord {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsErrorPage {
|
||||
pub items: Vec<OpsErrorRecord>,
|
||||
pub total: Option<u64>,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -44,54 +44,32 @@ pub(crate) async fn list_ops_errors(
|
||||
query: OpsErrorQuery,
|
||||
) -> StoreResult<OpsErrorPage> {
|
||||
query.filter.validate()?;
|
||||
let total = if query.include_total {
|
||||
Some(count_ops_errors(pool, query.range, &query.filter).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let total = count_ops_errors(pool, query.range, &query.filter).await?;
|
||||
let offset = observability_page_offset(query.current_page, query.page_size)?;
|
||||
let mut statement = QueryBuilder::<Postgres>::new("select * from (");
|
||||
statement.push(REQUEST_ERROR_SELECT);
|
||||
push_request_error_predicates(
|
||||
&mut statement,
|
||||
query.range,
|
||||
&query.filter,
|
||||
query.cursor.as_ref(),
|
||||
);
|
||||
push_request_error_predicates(&mut statement, query.range, &query.filter);
|
||||
statement.push(" union all ");
|
||||
statement.push(OPS_EVENT_SELECT);
|
||||
push_ops_event_predicates(
|
||||
&mut statement,
|
||||
query.range,
|
||||
&query.filter,
|
||||
query.cursor.as_ref(),
|
||||
);
|
||||
push_ops_event_predicates(&mut statement, query.range, &query.filter);
|
||||
statement.push(") e order by occurred_at desc, stable_sort_id desc limit ");
|
||||
statement.push_bind(i64::from(query.page_size.get()) + 1);
|
||||
statement.push_bind(i64::from(query.page_size.get()));
|
||||
statement.push(" offset ");
|
||||
statement.push_bind(offset);
|
||||
let rows = statement
|
||||
.build()
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("list ops errors"))?;
|
||||
let mut items = rows
|
||||
let items = rows
|
||||
.iter()
|
||||
.map(ops_error_from_row)
|
||||
.collect::<StoreResult<Vec<_>>>()?;
|
||||
let has_more = items.len() > usize::from(query.page_size.get());
|
||||
if has_more {
|
||||
items.pop();
|
||||
}
|
||||
let next_cursor = if has_more {
|
||||
items
|
||||
.last()
|
||||
.map(|item| ObservabilityCursor::new(item.occurred_at, item.stable_sort_id.clone()))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(OpsErrorPage {
|
||||
items,
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total,
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,11 +81,11 @@ pub(crate) async fn count_ops_errors(
|
||||
let mut statement = QueryBuilder::<Postgres>::new(
|
||||
"select coalesce(sum(source_count), 0)::bigint from (select count(*)::bigint as source_count from model_requests mr where mr.outcome = 'failed'",
|
||||
);
|
||||
push_request_error_predicates(&mut statement, range, filter, None);
|
||||
push_request_error_predicates(&mut statement, range, filter);
|
||||
statement.push(
|
||||
" union all select count(*)::bigint as source_count from ops_events oe left join model_requests mr on mr.id = oe.model_request_id where true",
|
||||
);
|
||||
push_ops_event_predicates(&mut statement, range, filter, None);
|
||||
push_ops_event_predicates(&mut statement, range, filter);
|
||||
statement.push(") counts");
|
||||
let total = statement
|
||||
.build_query_scalar::<i64>()
|
||||
@@ -121,7 +99,6 @@ fn push_request_error_predicates(
|
||||
statement: &mut QueryBuilder<Postgres>,
|
||||
range: ObservabilityRange,
|
||||
filter: &OpsErrorFilter,
|
||||
cursor: Option<&ObservabilityCursor>,
|
||||
) {
|
||||
push_range(statement, "mr.completed_at", range);
|
||||
for (column, value) in [
|
||||
@@ -162,19 +139,12 @@ fn push_request_error_predicates(
|
||||
search,
|
||||
);
|
||||
}
|
||||
push_cursor(
|
||||
statement,
|
||||
"mr.completed_at",
|
||||
"'model_request:' || mr.id",
|
||||
cursor,
|
||||
);
|
||||
}
|
||||
|
||||
fn push_ops_event_predicates(
|
||||
statement: &mut QueryBuilder<Postgres>,
|
||||
range: ObservabilityRange,
|
||||
filter: &OpsErrorFilter,
|
||||
cursor: Option<&ObservabilityCursor>,
|
||||
) {
|
||||
push_range(statement, "oe.created_at", range);
|
||||
for (column, value) in [
|
||||
@@ -217,7 +187,6 @@ fn push_ops_event_predicates(
|
||||
search,
|
||||
);
|
||||
}
|
||||
push_cursor(statement, "oe.created_at", "'ops_event:' || oe.id", cursor);
|
||||
}
|
||||
|
||||
fn push_range(statement: &mut QueryBuilder<Postgres>, column: &str, range: ObservabilityRange) {
|
||||
@@ -262,18 +231,3 @@ fn push_prefix_search(statement: &mut QueryBuilder<Postgres>, columns: &[&str],
|
||||
}
|
||||
statement.push(")");
|
||||
}
|
||||
|
||||
fn push_cursor(
|
||||
statement: &mut QueryBuilder<Postgres>,
|
||||
occurred_at: &str,
|
||||
stable_sort_id: &str,
|
||||
cursor: Option<&ObservabilityCursor>,
|
||||
) {
|
||||
if let Some(cursor) = cursor {
|
||||
statement.push(format!(" and ({occurred_at}, {stable_sort_id}) < ("));
|
||||
statement.push_bind(cursor.observed_at);
|
||||
statement.push(", ");
|
||||
statement.push_bind(cursor.stable_id.clone());
|
||||
statement.push(")");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,11 +156,22 @@ pub(crate) async fn list_usage_records(
|
||||
query: UsageRecordQuery,
|
||||
) -> StoreResult<UsageRecordPage> {
|
||||
query.filter.validate()?;
|
||||
let total = if query.include_total {
|
||||
Some(count_usage_records(pool, query.range, &query.filter).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let total = count_usage_records(pool, query.range, &query.filter).await?;
|
||||
let items = list_usage_record_items(pool, &query).await?;
|
||||
Ok(UsageRecordPage {
|
||||
items,
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn list_usage_record_items(
|
||||
pool: &PgPool,
|
||||
query: &UsageRecordQuery,
|
||||
) -> StoreResult<Vec<UsageListRecord>> {
|
||||
query.filter.validate()?;
|
||||
let offset = observability_page_offset(query.current_page, query.page_size)?;
|
||||
let mut statement = QueryBuilder::<Postgres>::new(USAGE_LIST_RECORD_SELECT);
|
||||
statement.push(" where mr.started_at >= ");
|
||||
statement.push_bind(query.range.start);
|
||||
@@ -168,41 +179,20 @@ pub(crate) async fn list_usage_records(
|
||||
statement.push_bind(query.range.end);
|
||||
push_completed_usage_fact_filter(&mut statement, "mr");
|
||||
push_usage_filter(&mut statement, &query.filter, "mr");
|
||||
if let Some(cursor) = &query.cursor {
|
||||
statement.push(" and (mr.started_at, mr.id) < (");
|
||||
statement.push_bind(cursor.observed_at);
|
||||
statement.push(", ");
|
||||
statement.push_bind(cursor.stable_id.clone());
|
||||
statement.push(")");
|
||||
}
|
||||
statement.push(" order by mr.started_at desc, mr.id desc limit ");
|
||||
statement.push_bind(i64::from(query.page_size.get()) + 1);
|
||||
statement.push_bind(i64::from(query.page_size.get()));
|
||||
statement.push(" offset ");
|
||||
statement.push_bind(offset);
|
||||
let rows = statement
|
||||
.build()
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("list usage records"))?;
|
||||
let mut items = rows
|
||||
let items = rows
|
||||
.iter()
|
||||
.map(usage_list_record_from_row)
|
||||
.collect::<StoreResult<Vec<_>>>()?;
|
||||
let has_more = items.len() > usize::from(query.page_size.get());
|
||||
if has_more {
|
||||
items.pop();
|
||||
}
|
||||
let next_cursor = if has_more {
|
||||
items
|
||||
.last()
|
||||
.map(|item| ObservabilityCursor::new(item.started_at, item.id.clone()))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(UsageRecordPage {
|
||||
items,
|
||||
total,
|
||||
next_cursor,
|
||||
})
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn count_usage_records(
|
||||
|
||||
@@ -106,13 +106,12 @@ async fn observability_preserves_and_filters_opaque_response_ids() {
|
||||
response_id: Some(response_id.clone()),
|
||||
..UsageRecordFilter::default()
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("filter opaque response ID");
|
||||
assert_eq!(records.total, Some(1));
|
||||
assert_eq!(records.total, 1);
|
||||
assert_eq!(records.items[0].id, "req_observe_success");
|
||||
let detail = repository
|
||||
.usage_record_detail("req_observe_success")
|
||||
@@ -131,8 +130,8 @@ async fn observability_preserves_and_filters_opaque_response_ids() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_page_should_skip_exact_count_when_total_is_not_requested() {
|
||||
let Some(database) = TestDatabase::create("usage_page_without_total").await else {
|
||||
async fn usage_page_should_always_return_total() {
|
||||
let Some(database) = TestDatabase::create("usage_page_with_total").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
@@ -146,14 +145,15 @@ async fn usage_page_should_skip_exact_count_when_total_is_not_requested() {
|
||||
.list_usage_records(UsageRecordQuery {
|
||||
range,
|
||||
filter: UsageRecordFilter::default(),
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: false,
|
||||
})
|
||||
.await
|
||||
.expect("usage page without total");
|
||||
.expect("usage page with total");
|
||||
|
||||
assert_eq!(page.total, None);
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.current_page, 1);
|
||||
assert_eq!(page.page_size, 10);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -176,14 +176,13 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() {
|
||||
search: Some("observe_success".to_owned()),
|
||||
..UsageRecordFilter::default()
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("usage substring search");
|
||||
|
||||
assert_eq!(page.total, Some(0));
|
||||
assert_eq!(page.total, 0);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -206,14 +205,13 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() {
|
||||
search: Some("req%".to_owned()),
|
||||
..OpsErrorFilter::default()
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("ops literal wildcard search");
|
||||
|
||||
assert_eq!(page.total, Some(0));
|
||||
assert_eq!(page.total, 0);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -625,20 +623,34 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
filter: admin_observability::UsageFilter::default(),
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(1).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("first usage page");
|
||||
assert_eq!(first_page.total, Some(1));
|
||||
assert_eq!(first_page.total, 1);
|
||||
assert_eq!(first_page.items.len(), 1);
|
||||
assert!(first_page.next_cursor.is_none());
|
||||
assert_eq!(first_page.current_page, 1);
|
||||
assert_eq!(first_page.page_size, 1);
|
||||
assert_eq!(
|
||||
first_page.items[0].service_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
|
||||
let deep_page = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
filter: admin_observability::UsageFilter::default(),
|
||||
current_page: 129,
|
||||
page_size: PageSize::new(1).expect("page size"),
|
||||
})
|
||||
.await
|
||||
.expect("direct deep usage page");
|
||||
assert_eq!(deep_page.current_page, 129);
|
||||
assert_eq!(deep_page.page_size, 1);
|
||||
assert_eq!(deep_page.total, 1);
|
||||
assert!(deep_page.items.is_empty());
|
||||
|
||||
let filtered = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
@@ -657,13 +669,12 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
upstream_request_id: Some("upstream_req_success".to_owned()),
|
||||
search: Some("req_observe_success".to_owned()),
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("fully filtered usage page");
|
||||
assert_eq!(filtered.total, Some(1));
|
||||
assert_eq!(filtered.total, 1);
|
||||
assert_eq!(filtered.items[0].id, "req_observe_success");
|
||||
assert_eq!(filtered.items[0].service_tier.as_deref(), Some("priority"));
|
||||
|
||||
@@ -677,13 +688,12 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
),
|
||||
..admin_observability::UsageFilter::default()
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("other outcome filter should reach PostgreSQL");
|
||||
assert_eq!(other_outcome.total, Some(0));
|
||||
assert_eq!(other_outcome.total, 0);
|
||||
assert!(other_outcome.items.is_empty());
|
||||
|
||||
let detail = store
|
||||
@@ -757,15 +767,28 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
search: Some("req_observe_failed".to_owned()),
|
||||
..admin_observability::OpsErrorFilter::default()
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("admin ops errors");
|
||||
assert_eq!(errors.total, Some(2));
|
||||
assert_eq!(errors.total, 2);
|
||||
assert!(errors.items.iter().all(|item| item.occurred_at <= now));
|
||||
|
||||
let deep_errors = store
|
||||
.list_ops_errors(admin_observability::OpsErrorQuery {
|
||||
range,
|
||||
filter: admin_observability::OpsErrorFilter::default(),
|
||||
current_page: 129,
|
||||
page_size: PageSize::new(1).expect("page size"),
|
||||
})
|
||||
.await
|
||||
.expect("direct deep ops page");
|
||||
assert_eq!(deep_errors.current_page, 129);
|
||||
assert_eq!(deep_errors.page_size, 1);
|
||||
assert_eq!(deep_errors.total, 2);
|
||||
assert!(deep_errors.items.is_empty());
|
||||
|
||||
for filter in [
|
||||
admin_observability::OpsErrorFilter {
|
||||
client_api_key_ref: Some("missing-key".to_owned()),
|
||||
@@ -796,13 +819,12 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
.list_ops_errors(admin_observability::OpsErrorQuery {
|
||||
range,
|
||||
filter,
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("fully forwarded ops filter");
|
||||
assert_eq!(page.total, Some(0));
|
||||
assert_eq!(page.total, 0);
|
||||
}
|
||||
|
||||
database.close().await;
|
||||
@@ -1052,13 +1074,12 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
provider_account_ref: Some("acct_observe".to_owned()),
|
||||
..UsageRecordFilter::default()
|
||||
},
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("usage records");
|
||||
assert_eq!(usage_page.total, Some(1));
|
||||
assert_eq!(usage_page.total, 1);
|
||||
let successful_image = usage_page
|
||||
.items
|
||||
.iter()
|
||||
@@ -1157,13 +1178,12 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
range,
|
||||
filter: OpsErrorFilter::default(),
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("ops errors");
|
||||
assert_eq!(errors.total, Some(2));
|
||||
assert_eq!(errors.total, 2);
|
||||
let request_error = errors
|
||||
.items
|
||||
.iter()
|
||||
|
||||
@@ -60,7 +60,7 @@ async fn completed_usage_projections_should_accept_statusless_websocket_but_reje
|
||||
|
||||
assert_eq!(
|
||||
(page.total, request_ids),
|
||||
(Some(1), vec!["req_statusless_websocket"])
|
||||
(1, vec!["req_statusless_websocket"])
|
||||
);
|
||||
|
||||
let overview = repository
|
||||
@@ -399,9 +399,8 @@ async fn ops_errors_should_keep_request_and_event_snapshots_after_account_deleti
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
range: range_around(started_at),
|
||||
filter: OpsErrorFilter::default(),
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
})
|
||||
.await
|
||||
.expect("list ops errors after deletion");
|
||||
@@ -886,9 +885,8 @@ fn usage_query(started_at: DateTime<Utc>, filter: UsageRecordFilter) -> UsageRec
|
||||
UsageRecordQuery {
|
||||
range: range_around(started_at),
|
||||
filter,
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).expect("page size"),
|
||||
include_total: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,10 +69,8 @@ use crate::transport::profile::{
|
||||
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
|
||||
};
|
||||
use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
|
||||
};
|
||||
use crate::transport::protocol::websocket::{
|
||||
PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
|
||||
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
|
||||
PreviousResponseScope, ResponseEventSignals,
|
||||
};
|
||||
use crate::transport::request::{
|
||||
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! 在 transport 边界采集的上游响应诊断信息。
|
||||
|
||||
use super::client::CodexClientVisibleUpstreamResponse;
|
||||
use super::protocol::responses::ResponsesSseFailure;
|
||||
use super::protocol::responses::{
|
||||
PREVIOUS_RESPONSE_NOT_FOUND_CODE, ResponsesSseFailure,
|
||||
is_bare_invalid_previous_response_id_error,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::{StatusCode, header::HeaderMap};
|
||||
@@ -140,7 +143,7 @@ impl CodexUpstreamFailure {
|
||||
rate_limit_headers: &[(String, String)],
|
||||
send_phase: CodexUpstreamSendPhase,
|
||||
) -> Self {
|
||||
let fields = ParsedUpstreamError::from_body(body);
|
||||
let fields = ParsedUpstreamError::from_http_response(status, body);
|
||||
let category = classify_upstream_failure(
|
||||
UpstreamFailureSource::HttpResponse,
|
||||
Some(status),
|
||||
@@ -260,7 +263,7 @@ struct ParsedUpstreamError {
|
||||
}
|
||||
|
||||
impl ParsedUpstreamError {
|
||||
fn from_body(body: &str) -> Self {
|
||||
fn from_http_response(status: StatusCode, body: &str) -> Self {
|
||||
let Ok(value) = serde_json::from_str::<Value>(body) else {
|
||||
return Self {
|
||||
code: None,
|
||||
@@ -275,11 +278,7 @@ impl ParsedUpstreamError {
|
||||
.or_else(|| value.get("error"))
|
||||
.or_else(|| value.get("detail"))
|
||||
.unwrap_or(&value);
|
||||
let code = error
|
||||
.get("code")
|
||||
.or_else(|| value.get("code"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(non_empty_owned);
|
||||
let code_value = error.get("code").or_else(|| value.get("code"));
|
||||
let error_type = error
|
||||
.get("type")
|
||||
.or_else(|| value.get("type"))
|
||||
@@ -290,6 +289,16 @@ impl ParsedUpstreamError {
|
||||
.or_else(|| value.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(non_empty_owned);
|
||||
let code = if status == StatusCode::BAD_REQUEST
|
||||
&& is_bare_invalid_previous_response_id_error(
|
||||
code_value,
|
||||
error_type.as_deref(),
|
||||
client_message.as_deref(),
|
||||
) {
|
||||
Some(PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned())
|
||||
} else {
|
||||
code_value.and_then(Value::as_str).and_then(non_empty_owned)
|
||||
};
|
||||
let message = client_message
|
||||
.clone()
|
||||
.or_else(|| error.as_str().and_then(non_empty_owned))
|
||||
|
||||
@@ -8,6 +8,24 @@ use reqwest::header::HeaderMap;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// 官方 Codex 客户端据此触发完整历史重放的稳定错误码。
|
||||
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
|
||||
/// 本地生成 history unavailable 错误时使用的官方提示文本。
|
||||
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
|
||||
"Previous response was not found. Retrying the full request.";
|
||||
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
|
||||
|
||||
/// 精确匹配上游省略错误码的旧版 previous response 拒绝形状。
|
||||
pub(crate) fn is_bare_invalid_previous_response_id_error(
|
||||
code: Option<&Value>,
|
||||
error_type: Option<&str>,
|
||||
message: Option<&str>,
|
||||
) -> bool {
|
||||
code.is_none_or(Value::is_null)
|
||||
&& error_type == Some("invalid_request_error")
|
||||
&& message == Some(INVALID_PREVIOUS_RESPONSE_ID_MESSAGE)
|
||||
}
|
||||
|
||||
/// Codex Responses 上游请求体。
|
||||
///
|
||||
/// 发往上游的 Responses 请求。`body` 持有客户端原始 JSON object,逐字段(含顺序、
|
||||
|
||||
@@ -2,13 +2,12 @@ use gateway_protocol::openai::sse::encode_sse_event;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::transport::protocol::responses::{CodexResponsesRequest, transport_requirement};
|
||||
use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE,
|
||||
is_bare_invalid_previous_response_id_error, transport_requirement,
|
||||
};
|
||||
|
||||
const REDACTED_PAYLOAD_VALUE: &str = "<redacted>";
|
||||
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
|
||||
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
|
||||
"Previous response was not found. Retrying the full request.";
|
||||
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
|
||||
|
||||
/// WebSocket 握手审计快照。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -87,11 +86,11 @@ fn previous_response_not_found_event(value: &Value) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let error = value.get("error")?.as_object()?;
|
||||
if error.get("code").is_some_and(|code| !code.is_null())
|
||||
|| error.get("type").and_then(Value::as_str) != Some("invalid_request_error")
|
||||
|| error.get("message").and_then(Value::as_str)
|
||||
!= Some(INVALID_PREVIOUS_RESPONSE_ID_MESSAGE)
|
||||
{
|
||||
if !is_bare_invalid_previous_response_id_error(
|
||||
error.get("code"),
|
||||
error.get("type").and_then(Value::as_str),
|
||||
error.get("message").and_then(Value::as_str),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -412,6 +412,7 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
"acct_truncated_stream",
|
||||
"acct_usage_limit_request_path",
|
||||
"acct_websocket_close",
|
||||
"acct_websocket_busy_replay",
|
||||
"acct_websocket_turn_state",
|
||||
]
|
||||
.into_iter()
|
||||
@@ -510,6 +511,40 @@ fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContex
|
||||
.with_continuation_attempt(ContinuationAttempt::ReplayAny)
|
||||
}
|
||||
|
||||
fn pinned_continuation_context(
|
||||
request_id: &str,
|
||||
account_id: &str,
|
||||
client_previous_response_id: &str,
|
||||
upstream_previous_response_id: &str,
|
||||
attempt_index: u32,
|
||||
continuation_attempt: ContinuationAttempt,
|
||||
) -> AttemptContext {
|
||||
let account = ProviderAccountId::new(account_id).expect("account id");
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let owner = ProviderAccountStateOwner::new(provider.clone(), account.clone());
|
||||
let continuation = NativeContinuationPin::new(
|
||||
PreviousResponseId::new(client_previous_response_id),
|
||||
PreviousResponseId::new(upstream_previous_response_id),
|
||||
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
|
||||
provider,
|
||||
account,
|
||||
);
|
||||
AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new(request_id).expect("request id"),
|
||||
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
|
||||
),
|
||||
NonZeroU32::new(attempt_index).expect("attempt index"),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(BTreeSet::new(), None, Some(owner))
|
||||
.with_account_scope(contract_account_scope()),
|
||||
Some(ContinuationBinding::Pinned(continuation)),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.with_continuation_attempt(continuation_attempt)
|
||||
}
|
||||
|
||||
fn external_continuation_context(request_id: &str) -> AttemptContext {
|
||||
AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
@@ -2919,6 +2954,288 @@ async fn same_account_previous_response_not_found_should_remain_client_visible()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_websocket_busy_then_http_invalid_previous_response_should_request_official_replay() {
|
||||
const ACCOUNT_ID: &str = "acct_websocket_busy_replay";
|
||||
const CONVERSATION_ID: &str = "conversation-websocket-busy-replay";
|
||||
const CLIENT_PREVIOUS_RESPONSE_ID: &str = "client-resp-busy-seed";
|
||||
const UPSTREAM_PREVIOUS_RESPONSE_ID: &str = "resp_busy_seed";
|
||||
|
||||
fn operation(
|
||||
previous_response_id: Option<&str>,
|
||||
session_state: ProviderSessionState,
|
||||
) -> Operation {
|
||||
let mut body = Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("hello")),
|
||||
]);
|
||||
if let Some(previous_response_id) = previous_response_id {
|
||||
body.insert(
|
||||
"previous_response_id".to_owned(),
|
||||
json!(previous_response_id),
|
||||
);
|
||||
}
|
||||
Operation::Generate(
|
||||
GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object("openai", body).expect("OpenAI payload"),
|
||||
)
|
||||
.with_provider_session_state(session_state),
|
||||
)
|
||||
}
|
||||
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, ACCOUNT_ID).await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind upstream listener");
|
||||
let base_url = format!(
|
||||
"http://{}",
|
||||
listener.local_addr().expect("listener address")
|
||||
);
|
||||
let (release_busy_sender, release_busy_receiver) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("accept WebSocket");
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
|
||||
let seed = websocket
|
||||
.next()
|
||||
.await
|
||||
.expect("seed request")
|
||||
.expect("valid seed request");
|
||||
let Message::Text(seed) = seed else {
|
||||
panic!("seed request must be text");
|
||||
};
|
||||
let seed: Value = serde_json::from_str(&seed).expect("seed request JSON");
|
||||
assert_eq!(seed.get("previous_response_id"), None);
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": UPSTREAM_PREVIOUS_RESPONSE_ID,
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("complete seed request");
|
||||
|
||||
let busy = websocket
|
||||
.next()
|
||||
.await
|
||||
.expect("busy continuation request")
|
||||
.expect("valid busy continuation request");
|
||||
let Message::Text(busy) = busy else {
|
||||
panic!("busy continuation request must be text");
|
||||
};
|
||||
let busy: Value = serde_json::from_str(&busy).expect("busy continuation JSON");
|
||||
assert_eq!(
|
||||
busy.get("previous_response_id").and_then(Value::as_str),
|
||||
Some(UPSTREAM_PREVIOUS_RESPONSE_ID)
|
||||
);
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "the exact WebSocket is busy"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send busy stream output");
|
||||
|
||||
let (mut http, _) = listener.accept().await.expect("accept HTTP fallback");
|
||||
read_http_request(&mut http).await;
|
||||
let body = json!({
|
||||
"error": {
|
||||
"code": null,
|
||||
"type": "invalid_request_error",
|
||||
"message": "Invalid `previous_response_id`."
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
http.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.expect("write HTTP 400");
|
||||
|
||||
release_busy_receiver.await.expect("release busy stream");
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_busy_finished",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("complete busy stream");
|
||||
});
|
||||
let provider = provider_with_base_url(&store, base_url);
|
||||
let initial_session_state = ProviderSessionState::new(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("account_id".to_owned(), json!(ACCOUNT_ID)),
|
||||
("conversation_id".to_owned(), json!(CONVERSATION_ID)),
|
||||
("continuation_scope".to_owned(), json!("connection_local")),
|
||||
]),
|
||||
)
|
||||
.expect("initial provider session state");
|
||||
|
||||
let mut seed = provider
|
||||
.execute(
|
||||
planned_request("openai", operation(None, initial_session_state)),
|
||||
context("req_ws_busy_seed", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare seed stream");
|
||||
let mut session_state = None;
|
||||
while let Some(event) = seed.next().await {
|
||||
let event = event.expect("seed event");
|
||||
if let Some(update) = event.session_update() {
|
||||
session_state = Some(update.clone());
|
||||
}
|
||||
}
|
||||
let session_state = session_state.expect("seed session update");
|
||||
assert_eq!(
|
||||
session_state
|
||||
.payload()
|
||||
.get("continuation_scope")
|
||||
.and_then(Value::as_str),
|
||||
Some("connection_local")
|
||||
);
|
||||
|
||||
let continuation = operation(Some(CLIENT_PREVIOUS_RESPONSE_ID), session_state);
|
||||
let mut busy = provider
|
||||
.execute(
|
||||
planned_request("openai", continuation.clone()),
|
||||
pinned_continuation_context(
|
||||
"req_ws_busy_owner",
|
||||
ACCOUNT_ID,
|
||||
CLIENT_PREVIOUS_RESPONSE_ID,
|
||||
UPSTREAM_PREVIOUS_RESPONSE_ID,
|
||||
1,
|
||||
ContinuationAttempt::Native,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("prepare busy continuation stream");
|
||||
loop {
|
||||
let event = busy
|
||||
.next()
|
||||
.await
|
||||
.expect("busy stream event")
|
||||
.expect("valid busy stream event");
|
||||
if event.has_client_event() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut exact = provider
|
||||
.execute(
|
||||
planned_request("openai", continuation.clone()),
|
||||
pinned_continuation_context(
|
||||
"req_ws_busy_exact",
|
||||
ACCOUNT_ID,
|
||||
CLIENT_PREVIOUS_RESPONSE_ID,
|
||||
UPSTREAM_PREVIOUS_RESPONSE_ID,
|
||||
1,
|
||||
ContinuationAttempt::Native,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("prepare competing exact continuation");
|
||||
let exact_error = loop {
|
||||
match exact.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("competing exact continuation must fail while its socket is busy"),
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
exact_error.continuation_failure(),
|
||||
Some(ContinuationFailure::Busy)
|
||||
);
|
||||
|
||||
let mut replay = provider
|
||||
.execute(
|
||||
planned_request("openai", continuation),
|
||||
pinned_continuation_context(
|
||||
"req_ws_busy_replay",
|
||||
ACCOUNT_ID,
|
||||
CLIENT_PREVIOUS_RESPONSE_ID,
|
||||
UPSTREAM_PREVIOUS_RESPONSE_ID,
|
||||
2,
|
||||
ContinuationAttempt::ReplayAny,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("prepare replay-any continuation");
|
||||
let replay_error = loop {
|
||||
match replay.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("HTTP fallback 400 must produce a provider error"),
|
||||
}
|
||||
};
|
||||
let detail = replay_error
|
||||
.client_visible_upstream_error()
|
||||
.expect("client-visible replay error");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
replay_error.kind(),
|
||||
replay_error.continuation_failure(),
|
||||
detail.code(),
|
||||
detail.error_type(),
|
||||
detail.message(),
|
||||
),
|
||||
(
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
Some(ContinuationFailure::HistoryUnavailable),
|
||||
Some("previous_response_not_found"),
|
||||
Some("invalid_request_error"),
|
||||
"Invalid `previous_response_id`.",
|
||||
)
|
||||
);
|
||||
|
||||
release_busy_sender.send(()).expect("release busy stream");
|
||||
while busy
|
||||
.next()
|
||||
.await
|
||||
.transpose()
|
||||
.expect("busy stream event")
|
||||
.is_some()
|
||||
{}
|
||||
server.await.expect("upstream server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continuation_prefetch_over_64_kib_should_commit_wire_without_protocol_failure() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -406,98 +406,26 @@ type OpsErrorPageQuery = UsagePageQuery & {
|
||||
route?: string
|
||||
}
|
||||
|
||||
interface CursorPageResponse<Item> {
|
||||
items: Item[]
|
||||
total: number | null
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type CursorPageQuery<Query> = Query & {
|
||||
pageSize: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
interface UsageDetailQuery {
|
||||
id: string
|
||||
}
|
||||
|
||||
type UsageDiagnosticsQuery = UsageRangeQuery & { dimension: string }
|
||||
|
||||
export function createUsageRecordsPager() {
|
||||
return createPageLoader<UsageListRecord, UsageRangeQuery>(data =>
|
||||
request<CursorPageResponse<UsageListRecord>>({
|
||||
url: '/api/admin/usage/records',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
}))
|
||||
export function getUsageRecords(data: UsagePageQuery) {
|
||||
return request<UsageRecordsResponse>({
|
||||
url: '/api/admin/usage/records',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function createOpsErrorsPager() {
|
||||
return createPageLoader<OpsError, Omit<OpsErrorPageQuery, keyof PageQuery>>(data =>
|
||||
request<CursorPageResponse<OpsError>>({
|
||||
url: '/api/admin/operations/errors',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
}))
|
||||
}
|
||||
|
||||
function createPageLoader<Item, Query extends object>(
|
||||
fetchPage: (query: CursorPageQuery<Query>) => Promise<CursorPageResponse<Item>>,
|
||||
) {
|
||||
const cursors = new Map<number, string | undefined>([[1, undefined]])
|
||||
let queryKey = ''
|
||||
let total = 0
|
||||
|
||||
function reset(nextQueryKey: string) {
|
||||
cursors.clear()
|
||||
cursors.set(1, undefined)
|
||||
queryKey = nextQueryKey
|
||||
total = 0
|
||||
}
|
||||
|
||||
async function load(data: Query & PageQuery) {
|
||||
const { currentPage, pageSize, ...query } = data
|
||||
const nextQueryKey = JSON.stringify([pageSize, query])
|
||||
if (queryKey !== nextQueryKey)
|
||||
reset(nextQueryKey)
|
||||
|
||||
const requestedPage = Math.max(1, Math.trunc(currentPage))
|
||||
let page = [...cursors.keys()].reduce(
|
||||
(known, candidate) => candidate <= requestedPage && candidate > known ? candidate : known,
|
||||
1,
|
||||
)
|
||||
let result: CursorPageResponse<Item>
|
||||
|
||||
while (page < requestedPage) {
|
||||
result = await fetchPage({
|
||||
...(query as Query),
|
||||
pageSize,
|
||||
cursor: cursors.get(page),
|
||||
})
|
||||
if (result.total != null)
|
||||
total = result.total
|
||||
if (!result.nextCursor)
|
||||
return { items: result.items, currentPage: page, pageSize, total }
|
||||
cursors.set(page + 1, result.nextCursor)
|
||||
page += 1
|
||||
}
|
||||
|
||||
result = await fetchPage({
|
||||
...(query as Query),
|
||||
pageSize,
|
||||
cursor: cursors.get(page),
|
||||
})
|
||||
if (result.total != null)
|
||||
total = result.total
|
||||
if (result.nextCursor)
|
||||
cursors.set(page + 1, result.nextCursor)
|
||||
else
|
||||
cursors.delete(page + 1)
|
||||
|
||||
return { items: result.items, currentPage: page, pageSize, total }
|
||||
}
|
||||
|
||||
return { load }
|
||||
export function getOpsErrors(data: OpsErrorPageQuery) {
|
||||
return request<OpsErrorsResponse>({
|
||||
url: '/api/admin/operations/errors',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function getUsageRecordDetail(data: UsageDetailQuery) {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface PageRequest {
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** Element Plus 风格的页面分页状态;稳定深分页由 API pager 内部完成。 */
|
||||
/** Element Plus 风格的服务端页码分页;一次 execute 只发起一次列表请求。 */
|
||||
export function useStablePagedQuery<Result extends PageResult>(options: {
|
||||
initialPageSize: number
|
||||
load: (pagination: PageRequest) => Promise<Result>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { UsageTimeRangeParams } from './useUsageTimeRange'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
import { createOpsErrorsPager } from '@/api'
|
||||
import { getOpsErrors } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
|
||||
import { errorMessage, withMinimumDuration } from '@/utils/async'
|
||||
@@ -13,11 +13,9 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
|
||||
const searchQuery = shallowRef('')
|
||||
const failureClass = shallowRef('')
|
||||
const route = shallowRef('')
|
||||
const pager = createOpsErrorsPager()
|
||||
|
||||
const query = useStablePagedQuery({
|
||||
initialPageSize: 10,
|
||||
load: ({ currentPage, pageSize }) => pager.load({
|
||||
load: ({ currentPage, pageSize }) => getOpsErrors({
|
||||
currentPage,
|
||||
pageSize,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
|
||||
@@ -5,9 +5,9 @@ import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
import { computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue'
|
||||
import {
|
||||
createUsageRecordsPager,
|
||||
getUsageRecordInsightsDiagnostics,
|
||||
getUsageRecordInsightsOverview,
|
||||
getUsageRecords,
|
||||
getUsageRecordSummary,
|
||||
} from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
@@ -26,13 +26,6 @@ interface UsageLoadOptions {
|
||||
background?: boolean
|
||||
}
|
||||
|
||||
interface UsageTableParams {
|
||||
startTime: string
|
||||
endTime: string
|
||||
provider?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
const loading = shallowRef(true)
|
||||
const analyticsLoading = shallowRef(true)
|
||||
@@ -49,7 +42,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
})
|
||||
const refreshingList = shallowRef(false)
|
||||
const diagnosticDimension = shallowRef('model')
|
||||
const pager = createUsageRecordsPager()
|
||||
let loadRequestId = 0
|
||||
let diagnosticRequestId = 0
|
||||
const scopedParams = () => ({
|
||||
@@ -70,21 +62,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
totalRecords.value = 0
|
||||
}
|
||||
|
||||
async function loadTablePage(
|
||||
params: UsageTableParams,
|
||||
targetPage: number,
|
||||
requestId: number,
|
||||
) {
|
||||
const result = await pager.load({
|
||||
currentPage: targetPage,
|
||||
pageSize: pageSize.value,
|
||||
...params,
|
||||
})
|
||||
if (requestId !== loadRequestId)
|
||||
return null
|
||||
return { result, page: result.currentPage }
|
||||
}
|
||||
|
||||
async function loadUsageRecords(loadOptions: UsageLoadOptions = {}) {
|
||||
const { scope = 'all', background = false } = loadOptions
|
||||
const requestId = ++loadRequestId
|
||||
@@ -114,8 +91,11 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
...tableTimeRangeParams.value,
|
||||
...filterParams(),
|
||||
}
|
||||
const requestedPage = currentPage.value
|
||||
const resultPromise = loadTablePage(tableParams, requestedPage, requestId)
|
||||
const resultPromise = getUsageRecords({
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
...tableParams,
|
||||
})
|
||||
const analyticsPromise
|
||||
= scope === 'all'
|
||||
? loadUsageAnalytics(globalParams)
|
||||
@@ -123,10 +103,9 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
summary: summary.value,
|
||||
insights: insights.value,
|
||||
})
|
||||
const [tableResult, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
|
||||
if (requestId !== loadRequestId || tableResult == null)
|
||||
const [result, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
|
||||
if (requestId !== loadRequestId)
|
||||
return
|
||||
const { result, page: resultPage } = tableResult
|
||||
|
||||
records.value = result.items
|
||||
summary.value = nextAnalytics.summary
|
||||
@@ -137,8 +116,9 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
? nextAnalytics.insights.diagnostics
|
||||
: insights.value.diagnostics,
|
||||
}
|
||||
pageSize.value = result.pageSize
|
||||
totalRecords.value = result.total
|
||||
currentPage.value = resultPage
|
||||
currentPage.value = result.currentPage
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (requestId !== loadRequestId)
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.3.10"
|
||||
version: "3.3.11"
|
||||
|
||||
Reference in New Issue
Block a user