refactor(store,api): split hotspot modules per boundary audit

Split the four oversized files into mod.rs-style module directories without
changing public ports or behavior:

- gateway-store: observability/{model,mapping,admin_adapter,queries/*},
  provider_accounts/{rows,repository,mapping,admin_adapter,core_adapter},
  root {config,bundle,workers,admin_adapter,value}
- gateway-api: admin/observability/{query,wire,routes,presenter} with
  mirrored contract tests and updated frozen architecture manifest

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-08-02 01:00:42 +08:00
co-authored by Codex
parent 2dc670875a
commit e02e5b186e
33 changed files with 10093 additions and 9887 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
//! Dashboard、用量与诊断查询的 wire 映射和固定路由。
use std::{collections::BTreeMap, num::NonZeroU32};
use axum::{
Router,
extract::{Query, 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};
use serde_json::Value;
use super::presenter::{format_compact_number, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
WireValidationError, wire::map_admin_service_error,
};
mod presenter;
mod query;
mod routes;
mod wire;
pub(crate) use presenter::*;
pub use query::*;
pub use routes::*;
pub use wire::*;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,463 @@
//! 查询 wire 类型、校验与领域命令映射。
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)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DashboardQuery {
pub kind: Option<String>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
impl DashboardQuery {
/// 解析 dashboard 趋势类型。
pub fn trend_kind(&self) -> Result<TrendKind, WireValidationError> {
TrendKind::parse(self.kind.as_deref())
}
}
/// 逻辑请求列表、汇总和洞察查询参数。
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsageQuery {
pub 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>,
pub provider: Option<String>,
pub request_id: Option<String>,
pub account_id: Option<String>,
pub route: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
impl UsageQuery {
/// 校验分页字段,不改变仓储层的分页 owner。
pub fn validate_page(&self) -> Result<(u32, u16), WireValidationError> {
let page = self.page.unwrap_or(1);
if page == 0 {
return Err(WireValidationError::new("page"));
}
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, page_size))
}
/// 校验游标的 wire 边界;编码和排序语义由应用层负责。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
}
}
/// 详情查询参数。
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DetailQuery {
pub id: String,
}
impl DetailQuery {
/// 校验详情 ID,错误不回显输入值。
pub fn validate(&self) -> Result<(), WireValidationError> {
require_text(&self.id, "id")
}
}
/// 诊断聚合查询参数。
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DiagnosticsQuery {
pub dimension: Option<String>,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub provider: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub search: Option<String>,
}
impl DiagnosticsQuery {
/// 解析诊断维度。
pub fn dimension(&self) -> Result<DiagnosticDimension, WireValidationError> {
DiagnosticDimension::parse(self.dimension.as_deref())
}
}
/// 运维错误查询参数。
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OpsQuery {
pub 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>,
pub request_id: Option<String>,
pub account_id: Option<String>,
pub route: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub failure_class: Option<String>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
impl OpsQuery {
/// 校验分页字段。
pub fn validate_page(&self) -> Result<(u32, u16), WireValidationError> {
let page = self.page.unwrap_or(1);
if page == 0 {
return Err(WireValidationError::new("page"));
}
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, page_size))
}
/// 校验游标的 wire 边界。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
}
}
/// Dashboard 趋势类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TrendKind {
Usage,
Latency,
Errors,
}
impl TrendKind {
/// 从 query 值解析趋势类型。
pub fn parse(value: Option<&str>) -> Result<Self, WireValidationError> {
match trimmed(value) {
None | Some("usage") => Ok(Self::Usage),
Some("latency") => Ok(Self::Latency),
Some("errors") => Ok(Self::Errors),
Some(_) => Err(WireValidationError::new("kind")),
}
}
}
/// 诊断聚合维度。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticDimension {
Model,
Account,
ApiKey,
Provider,
Transport,
Failure,
Status,
}
impl DiagnosticDimension {
/// 从 query 值解析诊断维度。
pub fn parse(value: Option<&str>) -> Result<Self, WireValidationError> {
match trimmed(value) {
None | Some("model") => Ok(Self::Model),
Some("account") => Ok(Self::Account),
Some("apiKey" | "api_key") => Ok(Self::ApiKey),
Some("provider") => Ok(Self::Provider),
Some("transport") => Ok(Self::Transport),
Some("failureClass" | "failure_class") => Ok(Self::Failure),
Some("status") => Ok(Self::Status),
Some(_) => Err(WireValidationError::new("dimension")),
}
}
/// 返回终态响应中的稳定维度名称。
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::Model => "model",
Self::Account => "account",
Self::ApiKey => "apiKey",
Self::Provider => "provider",
Self::Transport => "transport",
Self::Failure => "failureClass",
Self::Status => "status",
}
}
}
/// 解析 RFC3339 时间;错误不回显原始值。
pub fn parse_datetime(value: Option<&str>) -> Result<Option<DateTime<Utc>>, WireValidationError> {
let Some(value) = trimmed(value) else {
return Ok(None);
};
DateTime::parse_from_rfc3339(value)
.map(|value| Some(value.with_timezone(&Utc)))
.map_err(|_| WireValidationError::new("timeRange"))
}
/// 解析 HTTP 状态码。
pub fn parse_status(value: Option<i64>) -> Result<Option<u16>, WireValidationError> {
value
.map(|value| {
u16::try_from(value)
.ok()
.filter(|value| (100..=599).contains(value))
.ok_or_else(|| WireValidationError::new("statusCode"))
})
.transpose()
}
/// 解析尝试序号。
pub fn parse_attempt_index(value: Option<i64>) -> Result<Option<u32>, WireValidationError> {
value
.map(|value| {
u32::try_from(value)
.ok()
.filter(|value| *value > 0 && *value <= i32::MAX as u32)
.ok_or_else(|| WireValidationError::new("attemptIndex"))
})
.transpose()
}
pub(crate) fn require_text(value: &str, field: &'static str) -> Result<(), WireValidationError> {
if value.trim().is_empty() {
Err(WireValidationError::new(field))
} else {
Ok(())
}
}
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())
}
pub(crate) fn domain_trend_kind(kind: TrendKind) -> domain::TrendKind {
match kind {
TrendKind::Usage => domain::TrendKind::Usage,
TrendKind::Latency => domain::TrendKind::Latency,
TrendKind::Errors => domain::TrendKind::Errors,
}
}
pub(crate) fn domain_diagnostic_dimension(
dimension: DiagnosticDimension,
) -> domain::DiagnosticDimension {
match dimension {
DiagnosticDimension::Model => domain::DiagnosticDimension::Model,
DiagnosticDimension::Account => domain::DiagnosticDimension::Account,
DiagnosticDimension::ApiKey => domain::DiagnosticDimension::ApiKey,
DiagnosticDimension::Provider => domain::DiagnosticDimension::Provider,
DiagnosticDimension::Transport => domain::DiagnosticDimension::Transport,
DiagnosticDimension::Failure => domain::DiagnosticDimension::Failure,
DiagnosticDimension::Status => domain::DiagnosticDimension::Status,
}
}
pub(crate) fn request_outcome(
value: Option<String>,
) -> Result<Option<domain::RequestOutcome>, WireValidationError> {
non_empty(value)
.map(|value| {
domain::RequestOutcome::new(value).map_err(|_| WireValidationError::new("outcome"))
})
.transpose()
}
pub(crate) fn usage_range(
start: Option<&str>,
end: Option<&str>,
) -> Result<domain::TimeRange, WireValidationError> {
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
let start = parse_datetime(start)?.unwrap_or(end - Duration::days(7));
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
}
pub(crate) fn dashboard_range(
start: Option<&str>,
end: Option<&str>,
) -> Result<domain::TimeRange, WireValidationError> {
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
let start =
parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end) - Duration::days(1));
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
}
pub(crate) fn dashboard_today_range(
start: Option<&str>,
end: Option<&str>,
) -> Result<domain::TimeRange, WireValidationError> {
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
let start = parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end));
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
}
pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, WireValidationError> {
let outcome = query.outcome.clone().or_else(|| {
non_empty(query.kind.clone()).filter(|value| {
matches!(
value.as_str(),
"running" | "succeeded" | "failed" | "cancelled" | "incomplete"
)
})
});
Ok(domain::UsageFilter {
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
request_id: non_empty(query.request_id.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.route.clone()),
provider_kind: non_empty(query.provider.clone()),
model: non_empty(query.model.clone()),
outcome: request_outcome(outcome)?,
status_code: parse_status(query.status_code)?,
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
search: non_empty(query.search.clone()),
})
}
pub(crate) fn usage_command(
query: &UsageQuery,
) -> Result<(domain::UsageQuery, u32, u16), WireValidationError> {
let (page, page_size) = query.validate_page()?;
query.validate_cursor()?;
let page_number = NonZeroU32::new(page)
.map(domain::PageNumber::new)
.ok_or_else(|| WireValidationError::new("page"))?;
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())?,
page: page_number,
page_size: page_size_value,
},
page,
page_size,
))
}
pub(crate) fn ops_command(
query: &OpsQuery,
) -> Result<(domain::OpsErrorQuery, u32, u16), WireValidationError> {
let (page, page_size) = query.validate_page()?;
query.validate_cursor()?;
let page_number = NonZeroU32::new(page)
.map(domain::PageNumber::new)
.ok_or_else(|| WireValidationError::new("page"))?;
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
let status_code = parse_status(
query
.upstream_status_code
.or(query.client_status_code)
.or(query.status_code),
)?;
Ok((
domain::OpsErrorQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
filter: domain::OpsErrorFilter {
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
request_id: non_empty(query.request_id.clone()),
provider_kind: non_empty(query.provider.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.route.clone()).or_else(|| non_empty(query.kind.clone())),
model: non_empty(query.model.clone()),
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
failure_kind: non_empty(query.failure_class.clone()),
status_code,
search: non_empty(query.search.clone()),
},
cursor: decode_observability_cursor(query.cursor.as_deref())?,
page: page_number,
page_size: page_size_value,
},
page,
page_size,
))
}
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())
.filter(|value| !value.is_empty())
}
@@ -0,0 +1,213 @@
//! 固定 `/api/admin` 观测路由与 handler。
use super::*;
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/dashboard/summary", get(dashboard_summary::<S>))
.route("/api/admin/dashboard/trend", get(dashboard_trend::<S>))
.route("/api/admin/usage/records", get(usage_records::<S>))
.route(
"/api/admin/usage/records/detail",
get(usage_record_detail::<S>),
)
.route(
"/api/admin/usage/records/summary",
get(usage_records_summary::<S>),
)
.route(
"/api/admin/usage/insights/overview",
get(usage_insights_overview::<S>),
)
.route(
"/api/admin/usage/insights/diagnostics",
get(usage_insights_diagnostics::<S>),
)
.route("/api/admin/operations/errors", get(ops_errors::<S>))
}
pub(crate) async fn dashboard_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let kind = query.trend_kind().map_err(map_wire_error)?;
let range = dashboard_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.dashboard_summary(range, domain_trend_kind(kind))
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(dashboard_view(result, kind)),
))
}
pub(crate) async fn dashboard_trend<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let kind = query.trend_kind().map_err(map_wire_error)?;
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.dashboard_trend(range, domain_trend_kind(kind))
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(trend_view(result, kind)),
))
}
pub(crate) async fn usage_records<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (command, page, page_size) = usage_command(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.usage_records(command)
.await
.map_err(map_service_error)?;
let data = usage_page_view(result, page, page_size)
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
pub(crate) async fn usage_record_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
query.validate().map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.usage_record_detail(query.id.trim())
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(usage_detail_view(result)),
))
}
pub(crate) async fn usage_records_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let filter = usage_filter(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.usage_summary(range, filter)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(usage_summary_view(result)),
))
}
pub(crate) async fn usage_insights_overview<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let filter = usage_filter(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.usage_insights(range, filter)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(usage_insights_view(result)),
))
}
pub(crate) async fn usage_insights_diagnostics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DiagnosticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let dimension = query.dimension().map_err(map_wire_error)?;
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
.map_err(map_wire_error)?;
let filter = domain::UsageFilter {
provider_kind: non_empty(query.provider),
model: non_empty(query.model),
status_code: parse_status(query.status_code).map_err(map_wire_error)?,
search: non_empty(query.search),
..domain::UsageFilter::default()
};
let result = state
.admin_services()
.observability()
.diagnostics(range, filter, domain_diagnostic_dimension(dimension))
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(diagnostics_view(result, dimension)),
))
}
pub(crate) async fn ops_errors<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<OpsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (command, page, page_size) = ops_command(&query).map_err(map_wire_error)?;
let result = state
.admin_services()
.observability()
.ops_errors(command)
.await
.map_err(map_service_error)?;
let data = ops_page_view(result, page, page_size)
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -0,0 +1,742 @@
//! 响应 DTO 与固定 wire 形状。
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 page: PageMeta,
pub next_cursor: Option<String>,
}
/// Token 详情展示。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenDetailsView {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub image_input_tokens: Option<u64>,
pub image_output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub input_tokens_display: String,
pub output_tokens_display: String,
pub cached_tokens_display: String,
pub cache_write_tokens_display: String,
pub reasoning_tokens_display: String,
pub image_input_tokens_display: String,
pub image_output_tokens_display: String,
pub total_tokens_display: String,
}
/// 按货币展示的成本。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CostView {
pub currency: String,
pub estimated_amount: String,
}
/// 成本覆盖状态计数。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CostCoverageView {
pub known: u64,
pub partial: u64,
pub unknown: u64,
pub not_billable: u64,
}
/// Provider 受控价格规则生成的单次请求费用明细展示。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingView {
pub input_amount_display: String,
pub output_amount_display: String,
pub cache_read_amount_display: String,
pub cache_write_amount_display: String,
pub standard_amount_display: String,
pub total_amount_display: String,
pub input_price_display: String,
pub output_price_display: String,
pub cache_read_price_display: String,
pub cache_write_price_display: String,
pub service_tier_display: String,
pub multiplier_display: String,
}
/// 单条逻辑请求展示。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRecordView {
pub id: String,
pub request_id: String,
pub client_api_key_id: Option<String>,
pub kind: String,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
pub account_id: Option<String>,
pub account_email: Option<String>,
pub account_name: Option<String>,
pub route: String,
pub model: String,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub attempt_count: u64,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub image_input_tokens: Option<u64>,
pub image_output_tokens: Option<u64>,
pub message: String,
pub metadata: UsageRecordMetadataView,
pub created_at: DateTime<Utc>,
pub created_at_display: String,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub compact: Option<bool>,
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub token_details: TokenDetailsView,
pub billing: Option<BillingView>,
pub costs: Vec<CostView>,
pub cost_coverage: CostCoverageView,
pub first_token_latency_ms: Option<u64>,
pub first_token_latency_ms_display: String,
pub latency_ms_display: String,
pub logical_outcome: String,
}
/// 逻辑请求安全元数据;不承载原始请求体或上游错误正文。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRecordMetadataView {
pub protocol: String,
pub logical_outcome: String,
pub attempt_count: u64,
pub requested_model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_preset: Option<String>,
pub compact: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_status_code: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_status_code: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_request_id: Option<String>,
pub websocket_pool: Option<WebSocketPoolMetadataView>,
pub image_generation_requested: bool,
pub image_generation_succeeded: Option<bool>,
pub latency_details: UsageLatencyDetailsView,
/// Provider 安全观测直接展开,避免 API 层理解任何 Provider 专属字段。
#[serde(flatten)]
pub provider_metadata: BTreeMap<String, Value>,
}
/// WebSocket 池决策的稳定形状;与 Provider 选择逻辑无关。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebSocketPoolMetadataView {
pub kind: String,
}
/// 逻辑请求在上游和输出阶段测得的时延事实。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageLatencyDetailsView {
#[serde(skip_serializing_if = "Option::is_none")]
pub transport_decision_wait_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_connect_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_headers_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_event_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_reasoning_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_text_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_token_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub openai_processing_ms: Option<u64>,
}
/// 单次上游尝试展示。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageAttemptView {
pub id: String,
pub attempt_index: u32,
pub trigger: String,
pub provider: String,
pub model: String,
pub transport: String,
pub send_state: String,
pub outcome: String,
pub downstream_committed: bool,
pub status_code: Option<u16>,
pub provider_error_code: Option<String>,
pub failure_class: Option<String>,
pub cost_estimate_status: String,
pub estimated_cost_amount: Option<String>,
pub estimated_cost_currency: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub first_token_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub credential_name: Option<String>,
pub account_id: Option<String>,
pub account_name: Option<String>,
pub account_email: Option<String>,
pub authentication_kind: Option<String>,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
/// 逻辑请求详情与其尝试列表。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRecordDetailView {
#[serde(flatten)]
pub request: UsageRecordView,
pub attempts: Vec<UsageAttemptView>,
/// 尝试列表是否完整;best-effort 下恒为 false。
pub attempts_complete: bool,
}
/// Dashboard 趋势数据。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrendData {
pub kind: TrendKind,
pub points: Vec<TrendPointView>,
pub summary: Vec<TrendSummaryView>,
}
/// Dashboard 单个趋势桶。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrendPointView {
pub time: String,
pub bucket: DateTime<Utc>,
pub label: String,
pub requests: String,
pub requests_value: u64,
pub input_tokens: String,
pub input_tokens_value: u64,
pub output_tokens: String,
pub output_tokens_value: u64,
pub cached_tokens: String,
pub cached_tokens_value: u64,
pub cache_hit_rate_value: f64,
pub tokens_value: u64,
pub errors: String,
pub errors_value: u64,
pub latency: String,
pub latency_value: Option<u64>,
pub max_latency: String,
pub max_latency_value: Option<u64>,
pub min_latency: String,
pub min_latency_value: Option<u64>,
pub success_rate: String,
pub success_rate_value: Option<f64>,
}
/// Dashboard 趋势摘要。
#[derive(Debug, Serialize)]
pub struct TrendSummaryView {
pub label: String,
pub value: String,
pub ratio: Option<String>,
}
/// Dashboard 卡片集合。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCardsView {
pub credentials: DashboardCredentialsCardView,
pub traffic: DashboardTrafficCardView,
pub tokens: DashboardTokensCardView,
pub cache: DashboardCacheCardView,
}
/// Dashboard 上游凭据卡片。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCredentialsCardView {
pub total: String,
pub total_value: u64,
pub available: String,
pub available_value: u64,
pub unavailable: String,
pub unavailable_value: u64,
}
/// Dashboard 流量卡片。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTrafficCardView {
pub today_requests: String,
pub today_requests_value: u64,
pub yesterday_requests_value: u64,
pub total_requests: String,
}
/// Dashboard token 卡片。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardTokensCardView {
pub today_tokens: String,
pub today_tokens_value: u64,
pub yesterday_tokens_value: u64,
pub total_tokens: String,
pub total_billing_amount_usd: String,
}
/// Dashboard 缓存卡片。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCacheCardView {
pub today_hit_rate: String,
pub today_hit_rate_value: Option<f64>,
pub yesterday_hit_rate_value: Option<f64>,
pub total_hit_rate: String,
pub total_cached_tokens: String,
pub average_first_token_latency_ms: String,
}
/// 旧 Dashboard 账号概览卡片所需的 Provider 安全投影。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountUsageView {
pub id: String,
pub provider: String,
pub authentication_kind: String,
pub email: String,
pub plan_type: Option<String>,
pub tokens: String,
pub request_count: u64,
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
pub quota_used_percent: Option<f64>,
pub last_used: String,
}
/// Dashboard 账号单小时请求数。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountRequestBucketView {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
/// Provider 账号池的持久事实汇总。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardPoolSummaryView {
pub total: u64,
pub active: u64,
pub expired: u64,
pub quota_exhausted: u64,
pub refreshing: Option<u64>,
pub disabled: u64,
pub banned: u64,
}
/// 同 target 账号调度容量;Redis 未提供聚合事实时运行中槽位保持空值。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCapacityInfoView {
pub max_concurrent_per_account: u64,
pub total_slots: u64,
pub used_slots: Option<u64>,
pub available_slots: Option<u64>,
}
/// 逻辑请求指标展示。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestMetricsView {
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub caller_error_count: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub cache_write_tokens: u64,
pub reasoning_tokens: u64,
pub total_tokens: u64,
}
/// 上游尝试指标展示。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttemptMetricsView {
pub attempt_count: u64,
pub success_count: u64,
pub failure_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub rate_limited_count: u64,
pub auth_failure_count: u64,
pub provider5xx_count: u64,
pub cost_coverage: CostCoverageView,
pub costs: Vec<CostView>,
}
/// 健康时间线单点。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthTimelinePointView {
pub time: String,
pub status: String,
pub reliability_display: String,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
}
/// Dashboard 健康时间线。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthTimelineView {
pub title: String,
pub description: String,
pub reliability_display: String,
pub status: String,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub points: Vec<HealthTimelinePointView>,
}
/// Dashboard 展示的实际 Provider 上游请求身份。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardWireProfileView {
pub provider: String,
pub product: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
pub target: DashboardWireTargetView,
pub user_agent: String,
pub attributes: Vec<DashboardWireAttributeView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verified_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release: Option<DashboardDesktopReleaseView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardWireAttributeView {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardWireTargetView {
pub os_type: String,
pub os_version: String,
pub arch: String,
pub terminal: String,
}
/// 发布检查与启动画像分离;未检查时使用明确的 `unchecked` 状态。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardDesktopReleaseView {
pub status: DashboardDesktopReleaseStatusView,
#[serde(skip_serializing_if = "Option::is_none")]
pub checked_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_build: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Dashboard 发布检查的稳定 wire 状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DashboardDesktopReleaseStatusView {
Unchecked,
Aligned,
ReviewRequired,
CheckFailed,
}
impl From<domain::DesktopReleaseStatus> for DashboardDesktopReleaseStatusView {
fn from(status: domain::DesktopReleaseStatus) -> Self {
match status {
domain::DesktopReleaseStatus::Unchecked => Self::Unchecked,
domain::DesktopReleaseStatus::Current => Self::Aligned,
domain::DesktopReleaseStatus::UpdateAvailable => Self::ReviewRequired,
domain::DesktopReleaseStatus::Failed => Self::CheckFailed,
}
}
}
/// Dashboard 汇总响应数据。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardDataView {
pub cards: DashboardCardsView,
pub trend: TrendData,
pub health_timeline: HealthTimelineView,
pub wire_profiles: Vec<DashboardWireProfileView>,
pub account_usage: Vec<DashboardAccountUsageView>,
pub usage_records: Vec<UsageRecordView>,
pub pool_summary: DashboardPoolSummaryView,
pub capacity_info: DashboardCapacityInfoView,
pub rotation_strategy: String,
}
/// 用量汇总响应数据。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageSummaryView {
pub total_requests: String,
pub input_tokens: String,
pub output_tokens: String,
pub cached_tokens: String,
pub cache_write_tokens: String,
pub total_tokens: String,
pub average_latency_ms: String,
pub logical_requests: RequestMetricsView,
pub attempts: AttemptMetricsView,
}
/// 洞察健康趋势点。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewHealthPointView {
pub bucket: DateTime<Utc>,
pub label: String,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub error_rate: f64,
}
/// 洞察健康摘要。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewHealthView {
pub total_requests: u64,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub request_change_rate: Option<f64>,
pub success_rate_change: Option<f64>,
pub points: Vec<OverviewHealthPointView>,
}
/// 洞察性能趋势点。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewPerformancePointView {
pub bucket: DateTime<Utc>,
pub label: String,
pub latency_p50_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub latency_p99_ms: Option<f64>,
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
}
/// 洞察性能摘要。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewPerformanceView {
pub latency_p50_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub latency_p99_ms: Option<f64>,
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub points: Vec<OverviewPerformancePointView>,
}
/// 洞察成本趋势点。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewCostPointView {
pub bucket: DateTime<Utc>,
pub label: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub total_tokens: u64,
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
}
/// 洞察成本摘要。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewCostView {
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub cost_per_request: Option<String>,
pub tokens_per_request: f64,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub total_tokens: u64,
pub points: Vec<OverviewCostPointView>,
pub costs: Vec<CostView>,
pub coverage: CostCoverageView,
}
/// Provider 维度洞察摘要。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderOverviewView {
pub provider: String,
pub request_count: u64,
pub attempt_count: u64,
pub failure_count: u64,
pub total_tokens: u64,
}
/// 用量洞察总响应。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageInsightsOverviewView {
pub granularity: String,
pub health: OverviewHealthView,
pub performance: OverviewPerformanceView,
pub cost: OverviewCostView,
pub attempts: AttemptMetricsView,
pub providers: Vec<ProviderOverviewView>,
}
/// 诊断聚合项目。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticItemView {
pub key: String,
pub name: String,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
pub error_rate: f64,
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub estimated_cost: Option<String>,
pub attempt_count: u64,
pub total_tokens: u64,
}
/// 诊断聚合响应。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsView {
pub dimension: String,
pub items: Vec<DiagnosticItemView>,
}
/// 运维错误项目。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpsErrorView {
pub id: String,
pub request_id: Option<String>,
pub client_api_key_id: Option<String>,
pub kind: String,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
pub account_id: Option<String>,
pub route: String,
pub model: Option<String>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub failure_class: String,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub message: String,
pub metadata: OpsErrorMetadataView,
pub created_at: DateTime<Utc>,
pub created_at_display: String,
}
/// 运维错误安全元数据;只保留可查询的标识,不回显秘密材料。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpsErrorMetadataView {
pub source: String,
pub component: String,
pub attempt_id: Option<String>,
pub account_label: Option<String>,
}
@@ -0,0 +1,4 @@
//! Admin 观测 wire/路由的固定合同测试。
mod query;
mod response;
@@ -0,0 +1,64 @@
//! 查询 wire 校验的固定合同测试。
use gateway_api::admin::observability::{
DashboardQuery, DiagnosticDimension, DiagnosticsQuery, OpsQuery, TrendKind, UsageQuery,
parse_attempt_index, parse_datetime, parse_status,
};
use serde_json::json;
#[test]
fn dashboard_query_should_parse_terminal_trend_kinds() {
let query: DashboardQuery = serde_json::from_value(json!({"kind": "errors"})).unwrap();
assert_eq!(query.trend_kind().unwrap(), TrendKind::Errors);
}
#[test]
fn dashboard_query_should_reject_unknown_trend_kind() {
let query: DashboardQuery = serde_json::from_value(json!({"kind": "secret"})).unwrap();
assert_eq!(query.trend_kind().unwrap_err().field(), "kind");
}
#[test]
fn usage_query_should_bound_page_size_and_cursor() {
let query: UsageQuery = serde_json::from_value(json!({
"page": 2,
"pageSize": 100,
"cursor": "opaque"
}))
.unwrap();
assert_eq!(query.validate_page().unwrap(), (2, 100));
assert!(query.validate_cursor().is_ok());
}
#[test]
fn usage_query_should_reject_zero_page() {
let query: UsageQuery = serde_json::from_value(json!({"page": 0})).unwrap();
assert_eq!(query.validate_page().unwrap_err().field(), "page");
}
#[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().unwrap_err().field(), "pageSize");
}
#[test]
fn diagnostics_query_should_keep_wire_dimension_name() {
let query: DiagnosticsQuery =
serde_json::from_value(json!({"dimension": "failure_class"})).unwrap();
assert_eq!(query.dimension().unwrap(), DiagnosticDimension::Failure);
assert_eq!(DiagnosticDimension::Failure.display_name(), "failureClass");
}
#[test]
fn scalar_query_parsers_should_reject_out_of_range_values_without_echoing_input() {
assert_eq!(parse_status(Some(99)).unwrap_err().field(), "statusCode");
assert_eq!(
parse_attempt_index(Some(0)).unwrap_err().field(),
"attemptIndex"
);
assert_eq!(
parse_datetime(Some("not-a-time")).unwrap_err().field(),
"timeRange"
);
}
@@ -1,261 +1,196 @@
mod query {
use gateway_api::admin::observability::{
DashboardQuery, DiagnosticDimension, DiagnosticsQuery, OpsQuery, TrendKind, UsageQuery,
parse_attempt_index, parse_datetime, parse_status,
//! 响应 DTO 与固定 wire 形状的合同测试。
use chrono::{TimeZone, Utc};
use gateway_admin::model::observability::DesktopReleaseStatus;
use gateway_api::admin::PageMeta;
use gateway_api::admin::observability::{
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
DashboardWireProfileView, DashboardWireTargetView, PageData, TokenDetailsView, TrendData,
TrendKind, TrendPointView, TrendSummaryView,
};
use serde_json::json;
#[test]
fn usage_page_should_keep_terminal_camel_case_shape() {
let data = PageData {
items: vec![json!({"id": "request_1"})],
page: PageMeta::new(1, 50, 1, 1),
next_cursor: Some("cursor".to_owned()),
};
use serde_json::json;
#[test]
fn dashboard_query_should_parse_terminal_trend_kinds() {
let query: DashboardQuery = serde_json::from_value(json!({"kind": "errors"})).unwrap();
assert_eq!(query.trend_kind().unwrap(), TrendKind::Errors);
}
#[test]
fn dashboard_query_should_reject_unknown_trend_kind() {
let query: DashboardQuery = serde_json::from_value(json!({"kind": "secret"})).unwrap();
assert_eq!(query.trend_kind().unwrap_err().field(), "kind");
}
#[test]
fn usage_query_should_bound_page_size_and_cursor() {
let query: UsageQuery = serde_json::from_value(json!({
"page": 2,
"pageSize": 100,
"cursor": "opaque"
}))
.unwrap();
assert_eq!(query.validate_page().unwrap(), (2, 100));
assert!(query.validate_cursor().is_ok());
}
#[test]
fn usage_query_should_reject_zero_page() {
let query: UsageQuery = serde_json::from_value(json!({"page": 0})).unwrap();
assert_eq!(query.validate_page().unwrap_err().field(), "page");
}
#[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().unwrap_err().field(), "pageSize");
}
#[test]
fn diagnostics_query_should_keep_wire_dimension_name() {
let query: DiagnosticsQuery =
serde_json::from_value(json!({"dimension": "failure_class"})).unwrap();
assert_eq!(query.dimension().unwrap(), DiagnosticDimension::Failure);
assert_eq!(DiagnosticDimension::Failure.display_name(), "failureClass");
}
#[test]
fn scalar_query_parsers_should_reject_out_of_range_values_without_echoing_input() {
assert_eq!(parse_status(Some(99)).unwrap_err().field(), "statusCode");
assert_eq!(
parse_attempt_index(Some(0)).unwrap_err().field(),
"attemptIndex"
);
assert_eq!(
parse_datetime(Some("not-a-time")).unwrap_err().field(),
"timeRange"
);
}
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["page"]["pageSize"], 50);
assert_eq!(value["nextCursor"], "cursor");
}
mod response {
use chrono::{TimeZone, Utc};
use gateway_admin::model::observability::DesktopReleaseStatus;
use gateway_api::admin::PageMeta;
use gateway_api::admin::observability::{
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
DashboardWireProfileView, DashboardWireTargetView, PageData, TokenDetailsView, TrendData,
TrendKind, TrendPointView, TrendSummaryView,
};
use serde_json::json;
#[test]
fn dashboard_wire_profiles_should_keep_provider_specific_attributes() {
let value = serde_json::to_value(DashboardWireProfileView {
provider: "xai".to_owned(),
product: "Grok Build".to_owned(),
version: "0.2.106".to_owned(),
build: None,
target: DashboardWireTargetView {
os_type: "linux".to_owned(),
os_version: "—".to_owned(),
arch: "x86_64".to_owned(),
terminal: "headless".to_owned(),
},
user_agent: "grok-shell/0.2.106 (linux; x86_64)".to_owned(),
attributes: vec![DashboardWireAttributeView {
label: "客户端标识".to_owned(),
value: "grok-shell".to_owned(),
}],
verified_at: None,
release: None,
})
.expect("dashboard profile");
#[test]
fn usage_page_should_keep_terminal_camel_case_shape() {
let data = PageData {
items: vec![json!({"id": "request_1"})],
page: PageMeta::new(1, 50, 1, 1),
next_cursor: Some("cursor".to_owned()),
};
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["page"]["pageSize"], 50);
assert_eq!(value["nextCursor"], "cursor");
}
assert_eq!(value["provider"], "xai");
assert_eq!(value["version"], "0.2.106");
assert_eq!(value["attributes"][0]["label"], "客户端标识");
assert!(value.get("release").is_none());
assert!(value.get("verifiedAt").is_none());
}
#[test]
fn dashboard_wire_profiles_should_keep_provider_specific_attributes() {
let value = serde_json::to_value(DashboardWireProfileView {
provider: "xai".to_owned(),
product: "Grok Build".to_owned(),
version: "0.2.106".to_owned(),
build: None,
target: DashboardWireTargetView {
os_type: "linux".to_owned(),
os_version: "—".to_owned(),
arch: "x86_64".to_owned(),
terminal: "headless".to_owned(),
},
user_agent: "grok-shell/0.2.106 (linux; x86_64)".to_owned(),
attributes: vec![DashboardWireAttributeView {
label: "客户端标识".to_owned(),
value: "grok-shell".to_owned(),
}],
verified_at: None,
release: None,
})
.expect("dashboard profile");
assert_eq!(value["provider"], "xai");
assert_eq!(value["version"], "0.2.106");
assert_eq!(value["attributes"][0]["label"], "客户端标识");
assert!(value.get("release").is_none());
assert!(value.get("verifiedAt").is_none());
}
#[test]
fn dashboard_account_usage_should_keep_daily_request_timeline() {
let bucket_start = Utc.timestamp_opt(0, 0).single().unwrap();
let value = serde_json::to_value(DashboardAccountUsageView {
id: "account_1".to_owned(),
provider: "xai".to_owned(),
authentication_kind: "oauth".to_owned(),
email: "account@example.com".to_owned(),
plan_type: Some("free".to_owned()),
tokens: "—".to_owned(),
#[test]
fn dashboard_account_usage_should_keep_daily_request_timeline() {
let bucket_start = Utc.timestamp_opt(0, 0).single().unwrap();
let value = serde_json::to_value(DashboardAccountUsageView {
id: "account_1".to_owned(),
provider: "xai".to_owned(),
authentication_kind: "oauth".to_owned(),
email: "account@example.com".to_owned(),
plan_type: Some("free".to_owned()),
tokens: "—".to_owned(),
request_count: 3,
request_buckets: vec![DashboardAccountRequestBucketView {
bucket_start,
request_count: 3,
request_buckets: vec![DashboardAccountRequestBucketView {
bucket_start,
request_count: 3,
}],
quota_used_percent: None,
last_used: "刚刚".to_owned(),
})
.expect("dashboard account usage");
}],
quota_used_percent: None,
last_used: "刚刚".to_owned(),
})
.expect("dashboard account usage");
assert_eq!(value["requestCount"], 3);
assert_eq!(
value["requestBuckets"][0]["bucketStart"],
"1970-01-01T00:00:00Z"
);
assert_eq!(value["requestBuckets"][0]["requestCount"], 3);
}
assert_eq!(value["requestCount"], 3);
assert_eq!(
value["requestBuckets"][0]["bucketStart"],
"1970-01-01T00:00:00Z"
);
assert_eq!(value["requestBuckets"][0]["requestCount"], 3);
}
#[test]
fn trend_wire_should_serialize_kind_and_values_without_store_types() {
let now = Utc.timestamp_opt(0, 0).single().unwrap();
let data = TrendData {
kind: TrendKind::Usage,
points: vec![TrendPointView {
time: "08:00".to_owned(),
bucket: now,
label: "01-01 08:00".to_owned(),
requests: "1".to_owned(),
requests_value: 1,
input_tokens: "2".to_owned(),
input_tokens_value: 2,
output_tokens: "3".to_owned(),
output_tokens_value: 3,
cached_tokens: "0".to_owned(),
cached_tokens_value: 0,
cache_hit_rate_value: 0.0,
tokens_value: 5,
errors: "0".to_owned(),
errors_value: 0,
latency: "1 ms".to_owned(),
latency_value: Some(1),
max_latency: "1 ms".to_owned(),
max_latency_value: Some(1),
min_latency: "1 ms".to_owned(),
min_latency_value: Some(1),
success_rate: "100.0%".to_owned(),
success_rate_value: Some(100.0),
}],
summary: vec![TrendSummaryView {
label: "输入".to_owned(),
value: "2".to_owned(),
ratio: None,
}],
};
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["kind"], "usage");
assert_eq!(value["points"][0]["requestsValue"], 1);
}
#[test]
fn trend_wire_should_serialize_kind_and_values_without_store_types() {
let now = Utc.timestamp_opt(0, 0).single().unwrap();
let data = TrendData {
kind: TrendKind::Usage,
points: vec![TrendPointView {
time: "08:00".to_owned(),
bucket: now,
label: "01-01 08:00".to_owned(),
requests: "1".to_owned(),
requests_value: 1,
input_tokens: "2".to_owned(),
input_tokens_value: 2,
output_tokens: "3".to_owned(),
output_tokens_value: 3,
cached_tokens: "0".to_owned(),
cached_tokens_value: 0,
cache_hit_rate_value: 0.0,
tokens_value: 5,
errors: "0".to_owned(),
errors_value: 0,
latency: "1 ms".to_owned(),
latency_value: Some(1),
max_latency: "1 ms".to_owned(),
max_latency_value: Some(1),
min_latency: "1 ms".to_owned(),
min_latency_value: Some(1),
success_rate: "100.0%".to_owned(),
success_rate_value: Some(100.0),
}],
summary: vec![TrendSummaryView {
label: "输入".to_owned(),
value: "2".to_owned(),
ratio: None,
}],
};
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["kind"], "usage");
assert_eq!(value["points"][0]["requestsValue"], 1);
}
#[test]
fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
let coverage = CostCoverageView {
known: 1,
partial: 0,
unknown: 0,
not_billable: 0,
};
let token_details = TokenDetailsView {
input_tokens: Some(1),
output_tokens: Some(2),
cached_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
total_tokens: Some(3),
input_tokens_display: "1".to_owned(),
output_tokens_display: "2".to_owned(),
cached_tokens_display: "-".to_owned(),
cache_write_tokens_display: "-".to_owned(),
reasoning_tokens_display: "-".to_owned(),
image_input_tokens_display: "-".to_owned(),
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();
assert!(value.to_string().contains("known"));
assert!(!value.to_string().contains("secret"));
}
#[test]
fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
let coverage = CostCoverageView {
known: 1,
partial: 0,
unknown: 0,
not_billable: 0,
};
let token_details = TokenDetailsView {
input_tokens: Some(1),
output_tokens: Some(2),
cached_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
total_tokens: Some(3),
input_tokens_display: "1".to_owned(),
output_tokens_display: "2".to_owned(),
cached_tokens_display: "-".to_owned(),
cache_write_tokens_display: "-".to_owned(),
reasoning_tokens_display: "-".to_owned(),
image_input_tokens_display: "-".to_owned(),
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();
assert!(value.to_string().contains("known"));
assert!(!value.to_string().contains("secret"));
}
#[test]
fn billing_view_should_preserve_the_original_detail_contract() {
let value = serde_json::to_value(BillingView {
input_amount_display: "$0.03".to_owned(),
output_amount_display: "$0.00".to_owned(),
cache_read_amount_display: "$0.14".to_owned(),
cache_write_amount_display: "$0.00".to_owned(),
standard_amount_display: "$0.17".to_owned(),
total_amount_display: "$0.17".to_owned(),
input_price_display: "$10.0000 / 1M Token".to_owned(),
output_price_display: "$60.0000 / 1M Token".to_owned(),
cache_read_price_display: "$1.0000 / 1M Token".to_owned(),
cache_write_price_display: "$12.5000 / 1M Token".to_owned(),
service_tier_display: "Fast".to_owned(),
multiplier_display: "1.00x".to_owned(),
})
.expect("billing view");
#[test]
fn billing_view_should_preserve_the_original_detail_contract() {
let value = serde_json::to_value(BillingView {
input_amount_display: "$0.03".to_owned(),
output_amount_display: "$0.00".to_owned(),
cache_read_amount_display: "$0.14".to_owned(),
cache_write_amount_display: "$0.00".to_owned(),
standard_amount_display: "$0.17".to_owned(),
total_amount_display: "$0.17".to_owned(),
input_price_display: "$10.0000 / 1M Token".to_owned(),
output_price_display: "$60.0000 / 1M Token".to_owned(),
cache_read_price_display: "$1.0000 / 1M Token".to_owned(),
cache_write_price_display: "$12.5000 / 1M Token".to_owned(),
service_tier_display: "Fast".to_owned(),
multiplier_display: "1.00x".to_owned(),
})
.expect("billing view");
assert_eq!(value["inputAmountDisplay"], "$0.03");
assert_eq!(value["cacheReadPriceDisplay"], "$1.0000 / 1M Token");
assert_eq!(value["serviceTierDisplay"], "Fast");
assert_eq!(value["multiplierDisplay"], "1.00x");
}
assert_eq!(value["inputAmountDisplay"], "$0.03");
assert_eq!(value["cacheReadPriceDisplay"], "$1.0000 / 1M Token");
assert_eq!(value["serviceTierDisplay"], "Fast");
assert_eq!(value["multiplierDisplay"], "1.00x");
}
#[test]
fn desktop_release_status_should_preserve_the_existing_dashboard_wire_values() {
for (domain, expected) in [
(DesktopReleaseStatus::Unchecked, "unchecked"),
(DesktopReleaseStatus::Current, "aligned"),
(DesktopReleaseStatus::UpdateAvailable, "review_required"),
(DesktopReleaseStatus::Failed, "check_failed"),
] {
let status = DashboardDesktopReleaseStatusView::from(domain);
assert_eq!(serde_json::to_value(status).unwrap(), expected);
}
#[test]
fn desktop_release_status_should_preserve_the_existing_dashboard_wire_values() {
for (domain, expected) in [
(DesktopReleaseStatus::Unchecked, "unchecked"),
(DesktopReleaseStatus::Current, "aligned"),
(DesktopReleaseStatus::UpdateAvailable, "review_required"),
(DesktopReleaseStatus::Failed, "check_failed"),
] {
let status = DashboardDesktopReleaseStatusView::from(domain);
assert_eq!(serde_json::to_value(status).unwrap(), expected);
}
}
@@ -272,7 +207,7 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
@@ -344,7 +279,7 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
@@ -451,7 +386,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
@@ -536,7 +471,7 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
@@ -678,7 +613,7 @@ async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
@@ -714,7 +649,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
@@ -38,7 +38,11 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/admin/backups.rs",
"src/admin/client_keys.rs",
"src/admin/mod.rs",
"src/admin/observability.rs",
"src/admin/observability/mod.rs",
"src/admin/observability/presenter.rs",
"src/admin/observability/query.rs",
"src/admin/observability/routes.rs",
"src/admin/observability/wire.rs",
"src/admin/presenter.rs",
"src/admin/settings.rs",
"src/admin/system.rs",
@@ -73,7 +77,9 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/admin/auth.rs",
"tests/admin/client_keys.rs",
"tests/admin/mod.rs",
"tests/admin/observability.rs",
"tests/admin/observability/mod.rs",
"tests/admin/observability/query.rs",
"tests/admin/observability/response.rs",
"tests/admin/settings.rs",
"tests/admin/system.rs",
"tests/admin/wire.rs",
@@ -0,0 +1,296 @@
//! Admin 认证与设置 adapter。
use super::*;
pub(crate) struct AdminAuthStoreAdapter {
pub(crate) security: postgres::PgAdminSecurityAuditRepository,
pub(crate) settings: postgres::PgRuntimeSettingsRepository,
pub(crate) state: redis::RedisAdminAuthStateRepository,
}
pub(crate) struct AdminSettingsStoreAdapter {
pub(crate) control_plane: postgres::PgControlPlaneRepository,
}
#[async_trait::async_trait]
impl SettingsStore for AdminSettingsStoreAdapter {
async fn load_runtime_settings(&self) -> AdminStoreResult<AdminRuntimeSettings> {
let snapshot = postgres::ControlPlaneRepository::load_control_plane(&self.control_plane)
.await
.map_err(|error| admin_store_error("runtime settings", error))?;
admin_runtime_settings(snapshot.settings)
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
postgres::ControlPlaneRepository::load_control_plane(&self.control_plane)
.await
.map(|snapshot| snapshot.settings.admin_api_key.is_some())
.map_err(|error| admin_store_error("admin API key", error))
}
async fn replace_runtime_settings(
&self,
command: ReplaceRuntimeSettings,
context: &MutationContext,
) -> AdminStoreResult<AdminRuntimeSettings> {
let current = postgres::ControlPlaneRepository::load_control_plane(&self.control_plane)
.await
.map_err(|error| admin_store_error("runtime settings", error))?;
let replacement = postgres::ControlPlaneReplacement {
settings: postgres::RuntimeSettingsUpdate {
admin_api_key: current.settings.admin_api_key,
refresh_margin_seconds: command.refresh_margin_seconds,
refresh_concurrency: command.refresh_concurrency,
max_concurrent_per_account: command.max_concurrent_per_account,
request_interval_ms: command.request_interval_ms,
rotation_strategy: command.rotation_strategy.as_str().to_owned(),
model_mappings: store_model_mappings(command.model_mappings),
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
},
audit: mutation_audit(
context,
"settings.replace",
"runtime_settings",
"1",
vec![
"model_mappings_json".to_owned(),
"refresh_margin_seconds".to_owned(),
"refresh_concurrency".to_owned(),
"max_concurrent_per_account".to_owned(),
"request_interval_ms".to_owned(),
"rotation_strategy".to_owned(),
"retention".to_owned(),
],
),
};
let snapshot = postgres::ControlPlaneRepository::replace_control_plane(
&self.control_plane,
replacement,
)
.await
.map_err(|error| admin_store_error("runtime settings", error))?;
admin_runtime_settings(snapshot.settings)
}
async fn replace_admin_api_key(
&self,
key: AdminApiKey,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
self.replace_admin_api_key_value(Some(key.expose_for_auth().to_owned()), context)
.await
}
async fn delete_admin_api_key(
&self,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
self.replace_admin_api_key_value(None, context).await
}
}
impl AdminSettingsStoreAdapter {
async fn replace_admin_api_key_value(
&self,
admin_api_key: Option<String>,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
let exists = admin_api_key.is_some();
let revision = postgres::ControlPlaneRepository::replace_admin_api_key(
&self.control_plane,
admin_api_key,
mutation_audit(
context,
if exists {
"admin_api_key.replace"
} else {
"admin_api_key.delete"
},
"runtime_settings",
"1",
vec!["admin_api_key".to_owned()],
),
)
.await
.map_err(|error| admin_store_error("admin API key", error))?;
Ok(AdminApiKeyMutation {
config_revision: admin_revision(revision)?,
exists,
})
}
}
pub(crate) fn admin_runtime_settings(
settings: postgres::RuntimeSettings,
) -> AdminStoreResult<AdminRuntimeSettings> {
let rotation_strategy = AdminRotationStrategy::parse(settings.rotation_strategy.as_str())
.ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
"runtime settings",
"rotation strategy is invalid",
)
})?;
let model_mappings = settings
.model_mappings
.into_iter()
.map(|(public, upstream)| {
let public = gateway_core::routing::PublicModelId::new(public).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
"runtime settings",
"public model mapping is invalid",
)
})?;
let upstream = gateway_core::routing::UpstreamModelId::new(upstream).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
"runtime settings",
"upstream model mapping is invalid",
)
})?;
Ok((public, upstream))
})
.collect::<AdminStoreResult<ModelMappings>>()?;
Ok(AdminRuntimeSettings {
config_revision: admin_revision(settings.config_revision)?,
model_mappings,
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: settings.refresh_concurrency,
max_concurrent_per_account: settings.max_concurrent_per_account,
request_interval_ms: settings.request_interval_ms,
rotation_strategy,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
updated_at: settings.updated_at,
})
}
pub(crate) fn store_model_mappings(
mappings: ModelMappings,
) -> std::collections::BTreeMap<String, String> {
mappings
.into_iter()
.map(|(public, upstream)| (public.as_str().to_owned(), upstream.as_str().to_owned()))
.collect()
}
#[async_trait::async_trait]
impl AuthStore for AdminAuthStoreAdapter {
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>> {
postgres::AdminSecurityAuditRepository::password_hash(&self.security, admin_user_id)
.await
.map_err(|error| admin_store_error("admin authentication", error))
}
async fn create_password_hash_if_absent(
&self,
admin_user_id: &str,
password_hash: &str,
) -> AdminStoreResult<bool> {
postgres::AdminSecurityAuditRepository::create_password_hash_if_absent(
&self.security,
admin_user_id,
password_hash,
)
.await
.map_err(|error| admin_store_error("admin authentication", error))
}
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>> {
postgres::RuntimeSettingsRepository::load_runtime_settings(&self.settings)
.await
.map(|settings| settings.admin_api_key.map(AdminApiKey::new))
.map_err(|error| admin_store_error("admin API key", error))
}
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
redis::AdminAuthStateRepository::load_admin_session(&self.state, session_id)
.await
.map(|session| {
session.map(|record| AdminSession {
admin_user_id: record.admin_user_id,
expires_at: record.expires_at,
})
})
.map_err(|error| admin_store_error("admin session", error))
}
async fn store_session(
&self,
session_id: &str,
session: &AdminSession,
) -> AdminStoreResult<()> {
redis::AdminAuthStateRepository::store_admin_session(
&self.state,
session_id,
&redis::AdminSessionRecord {
admin_user_id: session.admin_user_id.clone(),
expires_at: session.expires_at,
},
)
.await
.map_err(|error| admin_store_error("admin session", error))
}
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
redis::AdminAuthStateRepository::delete_admin_session(&self.state, session_id)
.await
.map(|session| {
session.map(|record| AdminSession {
admin_user_id: record.admin_user_id,
expires_at: record.expires_at,
})
})
.map_err(|error| admin_store_error("admin session", error))
}
async fn append_audit_event(&self, event: AdminAuditModel) -> AdminStoreResult<()> {
let config_revision = event
.config_revision
.map(|revision| i64::try_from(revision.get()))
.transpose()
.map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
"admin audit",
"config revision is outside the supported range",
)
})?;
let actor_kind = match event.actor_kind {
gateway_admin::model::auth::AuditActorKind::AdminSession => {
postgres::AdminAuditActorKind::AdminSession
}
gateway_admin::model::auth::AuditActorKind::AdminApiKey => {
postgres::AdminAuditActorKind::AdminApiKey
}
gateway_admin::model::auth::AuditActorKind::System => {
postgres::AdminAuditActorKind::System
}
gateway_admin::model::auth::AuditActorKind::Anonymous => {
postgres::AdminAuditActorKind::Anonymous
}
};
postgres::AdminSecurityAuditRepository::append_admin_audit_event(
&self.security,
postgres::AdminAuditEvent {
id: event.id,
actor_kind,
actor_admin_user_id: event.actor_admin_user_id,
actor_ref: event.actor_ref,
admin_request_id: event.request_id,
action: event.action,
entity_kind: event.entity_kind,
entity_ref: event.entity_ref,
config_revision,
changed_fields: event.changed_fields,
created_at: event.occurred_at,
},
)
.await
.map_err(|error| admin_store_error("admin audit", error))
}
}
+211
View File
@@ -0,0 +1,211 @@
//! 完成连接、迁移与 hydration 的 Store 能力集合与启动屏障。
use super::*;
/// 已完成连接、迁移与 cooldown hydration 的 Store 能力集合。
pub struct StoreBundle {
admin_ports: AdminStorePorts,
core_ports: CoreStorePorts,
provider_ports: ProviderStorePorts,
worker_leader_lease: Arc<dyn WorkerLeaderLeasePort>,
health_probes: Vec<Arc<dyn HealthProbe>>,
worker_contributions: Vec<WorkerContribution>,
}
impl StoreBundle {
#[must_use]
pub fn admin_ports(&self) -> AdminStorePorts {
self.admin_ports.clone()
}
#[must_use]
pub fn core_ports(&self) -> CoreStorePorts {
self.core_ports.clone()
}
#[must_use]
pub fn provider_ports(&self) -> ProviderStorePorts {
self.provider_ports.clone()
}
#[must_use]
pub fn worker_leader_lease(&self) -> Arc<dyn WorkerLeaderLeasePort> {
Arc::clone(&self.worker_leader_lease)
}
#[must_use]
pub fn health_probes(&self) -> Vec<Arc<dyn HealthProbe>> {
self.health_probes.clone()
}
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
std::mem::take(&mut self.worker_contributions)
}
}
/// 在返回 Bundle 前完成全部 Store 启动屏障。
pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
const REDIS_NAMESPACE: &str = "codex-proxy-rs";
config.resolve_and_validate(std::path::Path::new("."))?;
let pool = postgres::connect_and_migrate(&config.database_url()?, config.pool).await?;
let redis_client = ::redis::Client::open(config.redis_url()?)
.map_err(|_| redis_unavailable("create Redis client"))?;
let redis_connection = redis_client
.get_connection_manager()
.await
.map_err(|_| redis_unavailable("connect Redis manager"))?;
let provider_accounts = Arc::new(postgres::PgProviderAccountRepository::new(pool.clone()));
let cooldowns = Arc::new(redis::RedisCredentialCooldownRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let account_store = Arc::new(redis::CooldownCachingProviderAccountStore::new(
provider_accounts,
cooldowns.clone(),
));
let hydrated = account_store.hydrate(std::time::SystemTime::now()).await;
tracing::info!(hydrated, "账号 cooldown Redis 热缓存重建完成");
let credential_leases =
redis::RedisCredentialLeaseRepository::new(redis_connection.clone(), REDIS_NAMESPACE)?;
let provider_leases = Arc::new(redis::RedisProviderLeaseCoordinator::new(
credential_leases.clone(),
));
let provider_session_affinity = Arc::new(redis::RedisProviderSessionAffinityRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let credential_state = Arc::new(redis::RedisCredentialStateRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let runtime_policy = Arc::new(postgres::PgRuntimeSettingsRepository::new(pool.clone()));
let oauth_pending = Arc::new(redis::RedisOAuthPendingFlowRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let admin_ports = AdminStorePorts::new(
Arc::new(postgres::PgAdminAccountStore::new(pool.clone())),
Arc::new(AdminAuthStoreAdapter {
security: postgres::PgAdminSecurityAuditRepository::new(pool.clone()),
settings: postgres::PgRuntimeSettingsRepository::new(pool.clone()),
state: redis::RedisAdminAuthStateRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?,
}),
Arc::new(postgres::PgAdminClientKeyStore::new(pool.clone())),
Arc::new(postgres::PgAdminObservabilityStore::new(
pool.clone(),
Some(credential_leases.clone()),
)),
Arc::new(AdminSettingsStoreAdapter {
control_plane: postgres::PgControlPlaneRepository::new(pool.clone()),
}),
backup_ports(pool.clone(), &config)?,
);
let execution_repository = Arc::new(postgres::PgExecutionStore::new(pool.clone()));
let (execution, execution_writer) =
postgres::BufferedExecutionStore::new(Arc::clone(&execution_repository));
let execution = Arc::new(execution);
let retention = Arc::new(postgres::PgRetentionRepository::new(pool.clone()));
let admissions: Arc<dyn gateway_core::engine::admission::ClientAdmissionPort> = Arc::new(
redis::RedisClientAdmissionRepository::new(redis_connection.clone(), REDIS_NAMESPACE)?,
);
let circuits: Arc<dyn gateway_core::engine::execution::ProviderCircuitPort> =
Arc::new(redis::RedisProviderCircuitRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
gateway_core::engine::execution::ProviderCircuitPolicy::default(),
)?);
// Continuation affinity 是下一轮请求的路由事实,Core 必须直接等待 Redis 确认。
let continuation: Arc<dyn gateway_core::engine::continuation::NativeContinuationPort> =
Arc::new(redis::RedisNativeContinuationRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let (admissions, admission_release_writer) =
redis::BufferedClientAdmissionPort::new(admissions);
let (circuits, circuit_feedback_writer) = redis::BufferedProviderCircuitPort::new(circuits);
let core_ports = CoreStorePorts::new(
execution,
(
Arc::new(admissions),
Arc::new(postgres::PgClientAdmissionRecoveryRepository::new(
pool.clone(),
)),
),
Arc::new(circuits),
continuation,
(
Arc::new(postgres::PgRuntimeSnapshotRepository::new(pool.clone())),
Arc::new(redis::RedisRuntimeChangeRepository::new(
redis_client,
REDIS_NAMESPACE,
)?),
),
Arc::new(postgres::PgClientApiKeyUsageSink::new(pool.clone())),
);
let provider_ports = ProviderStorePorts::new(
account_store,
provider_leases,
provider_session_affinity,
Arc::new(redis::RedisProviderSessionExclusionRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?),
credential_state.clone(),
credential_state,
cooldowns,
runtime_policy,
oauth_pending,
);
let worker_leader_lease = Arc::new(redis::worker_lease::RedisWorkerLeaderLeasePort::new(
credential_leases,
));
let health_probes: Vec<Arc<dyn HealthProbe>> = vec![
Arc::new(PostgresHealthProbe { pool: pool.clone() }),
Arc::new(RedisHealthProbe {
connection: redis_connection,
}),
];
let worker_contributions = store_worker_contributions(
execution_repository,
execution_writer,
admission_release_writer,
circuit_feedback_writer,
retention,
)?;
Ok(StoreBundle {
admin_ports,
core_ports,
provider_ports,
worker_leader_lease,
health_probes,
worker_contributions,
})
}
/// 构造备份控制面的仓储、导出器与对象存储适配器。
pub(crate) fn backup_ports(
pool: sqlx::PgPool,
config: &StoreConfig,
) -> StoreResult<BackupStorePorts> {
let staging = Arc::new(backup::staging::StagingArea::open(
std::path::PathBuf::from(".runtime/data/backup-staging"),
backup::staging::DEFAULT_MAX_ARCHIVE_BYTES,
)?);
let repository = Arc::new(postgres::PgBackupRepository::new(pool));
let dump = Arc::new(backup::pg_dump::PgDumpAdapter::new(
staging,
config.database.url.clone(),
config.database.password.clone(),
));
let object_store = Arc::new(backup::s3::S3ObjectStoreAdapter::new());
Ok(BackupStorePorts::new(repository, dump, object_store))
}
+141
View File
@@ -0,0 +1,141 @@
//! Store 启动配置、环境变量解析与校验。
use super::*;
pub(crate) const DATABASE_URL_ENV: &str = "CPR_DATABASE_URL";
pub(crate) const REDIS_URL_ENV: &str = "CPR_REDIS_URL";
pub(crate) const DATABASE_PASSWORD_ENV: &str = "CPR_DATABASE_PASSWORD";
pub(crate) const REDIS_PASSWORD_ENV: &str = "CPR_REDIS_PASSWORD";
/// Store 自己拥有并校验的启动配置。
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoreConfig {
pub(crate) database: StoreConnectionConfig,
pub(crate) redis: StoreConnectionConfig,
#[serde(default)]
pub(crate) pool: StorePoolConfig,
}
/// PostgreSQL 连接池预算;acquire 超时决定池耗尽时快速失败而非排队积压。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct StorePoolConfig {
pub max_connections: u32,
pub acquire_timeout_seconds: u64,
}
impl Default for StorePoolConfig {
fn default() -> Self {
Self {
max_connections: 10,
acquire_timeout_seconds: 5,
}
}
}
impl StorePoolConfig {
fn validate(&self) -> StoreResult<()> {
if self.max_connections == 0 || self.acquire_timeout_seconds == 0 {
return Err(StoreError::InvalidData {
entity: "store config",
message: "pool limits must be positive".to_owned(),
});
}
Ok(())
}
}
impl StoreConfig {
pub fn resolve_and_validate(&mut self, _source_dir: &std::path::Path) -> StoreResult<()> {
if let Some(url) = optional_environment_value(DATABASE_URL_ENV)? {
self.database.url = url;
}
if let Some(url) = optional_environment_value(REDIS_URL_ENV)? {
self.redis.url = url;
}
if let Some(password) = optional_environment_value(DATABASE_PASSWORD_ENV)? {
self.database.password = password;
}
if let Some(password) = optional_environment_value(REDIS_PASSWORD_ENV)? {
self.redis.password = password;
}
self.database.validate("database")?;
self.redis.validate("redis")?;
self.pool.validate()?;
Ok(())
}
pub(crate) fn database_url(&self) -> StoreResult<String> {
self.database.connection_url("database")
}
pub(crate) fn redis_url(&self) -> StoreResult<String> {
self.redis.connection_url("redis")
}
}
pub(crate) fn optional_environment_value(name: &'static str) -> StoreResult<Option<String>> {
match std::env::var(name) {
Ok(value) if value.trim().is_empty() => Err(StoreError::InvalidData {
entity: "store config",
message: format!("environment variable {name} is empty"),
}),
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(StoreError::InvalidData {
entity: "store config",
message: format!("environment variable {name} is not Unicode"),
}),
}
}
impl fmt::Debug for StoreConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("StoreConfig")
.field("database", &"[REDACTED]")
.field("redis", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct StoreConnectionConfig {
pub(crate) url: String,
pub(crate) password: String,
}
impl StoreConnectionConfig {
fn validate(&self, field: &'static str) -> StoreResult<()> {
require_nonempty("store config", field, &self.url)?;
if self.password.len() != 48 || !self.password.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(StoreError::InvalidData {
entity: "store config",
message: format!("{field}.password must be exactly 48 hexadecimal characters"),
});
}
self.connection_url(field).map(|_| ())
}
fn connection_url(&self, field: &'static str) -> StoreResult<String> {
let mut url = url::Url::parse(&self.url).map_err(|_| StoreError::InvalidData {
entity: "store config",
message: format!("{field}.url is invalid"),
})?;
if url.password().is_some() {
return Err(StoreError::InvalidData {
entity: "store config",
message: format!("{field}.url must not contain a password"),
});
}
url.set_password(Some(&self.password))
.map_err(|()| StoreError::InvalidData {
entity: "store config",
message: format!("{field}.url cannot carry credentials"),
})?;
Ok(url.to_string())
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
//! Pg 观测 adapter:实现 `ObservabilityRepository` 与 `AdminObservabilityStore`。
use super::*;
use crate::redis::{CredentialLeaseRepository as _, RedisCredentialLeaseRepository};
#[derive(Clone)]
pub struct PgObservabilityRepository {
pool: PgPool,
}
impl PgObservabilityRepository {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// `gateway-admin` 观测端口的 PostgreSQL adapter。
///
/// SQL 查询与内部投影继续由 [`PgObservabilityRepository`] 唯一拥有;本类型只负责
/// Admin UTC 领域模型与持久化投影之间的无格式化转换。
#[derive(Clone)]
pub struct PgAdminObservabilityStore {
repository: PgObservabilityRepository,
runtime_signals: Option<RedisCredentialLeaseRepository>,
}
impl PgAdminObservabilityStore {
#[must_use]
pub fn new(pool: PgPool, runtime_signals: Option<RedisCredentialLeaseRepository>) -> Self {
Self {
repository: PgObservabilityRepository::new(pool),
runtime_signals,
}
}
}
#[async_trait]
impl ObservabilityRepository for PgObservabilityRepository {
async fn dashboard_summary(
&self,
range: ObservabilityRange,
) -> StoreResult<DashboardObservation> {
let filter = UsageRecordFilter::default();
let account_usage_range = ObservabilityRange::new(
range.end - TimeDelta::hours(ACCOUNT_USAGE_TIMELINE_HOURS),
range.end,
)?;
let account_usage_query =
ProviderAccountUsageQuery::recent(account_usage_range, DASHBOARD_ACCOUNT_LIMIT)?
.with_hourly_request_buckets()?;
let recent_query = UsageRecordQuery {
range,
filter: UsageRecordFilter {
outcome: Some("succeeded".to_owned()),
..UsageRecordFilter::default()
},
cursor: None,
page: ObservabilityPageNumber::new(1)?,
page_size: ObservabilityPageSize::new(10)?,
};
// 分两批并发:页面延迟从六路之和降为两批各自最慢者之和,同时单次
// 渲染最多占用 3 个池连接,不与数据面写路径争抢整个连接池。
let (requests, attempts, provider_accounts) = futures::try_join!(
request_metrics(&self.pool, range, &filter),
attempt_metrics(&self.pool, range, &filter),
provider_account_metrics(&self.pool, range.end),
)?;
let (trend, account_usage, recent_requests) = futures::try_join!(
request_metric_series(&self.pool, range, &filter),
provider_account_usage(&self.pool, account_usage_query),
async { Ok(list_usage_records(&self.pool, recent_query).await?.items) },
)?;
Ok(DashboardObservation {
range,
requests,
attempts,
provider_accounts,
trend,
account_usage,
recent_requests,
})
}
async fn dashboard_trend(
&self,
range: ObservabilityRange,
) -> StoreResult<Vec<RequestMetricPoint>> {
request_metric_series(&self.pool, range, &UsageRecordFilter::default()).await
}
async fn usage_trend(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
) -> StoreResult<Vec<RequestMetricPoint>> {
request_metric_series(&self.pool, range, &filter).await
}
async fn usage_calculated_billing_facts(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
) -> StoreResult<Vec<CalculatedUsageBillingFact>> {
calculated_usage_billing_facts(&self.pool, range, &filter).await
}
async fn provider_account_usage(
&self,
query: ProviderAccountUsageQuery,
) -> StoreResult<Vec<ProviderAccountUsageObservation>> {
provider_account_usage(&self.pool, query).await
}
async fn list_usage_records(&self, query: UsageRecordQuery) -> StoreResult<UsageRecordPage> {
list_usage_records(&self.pool, query).await
}
async fn usage_record_detail(&self, request_id: &str) -> StoreResult<UsageRecordDetail> {
usage_record_detail(&self.pool, request_id).await
}
async fn usage_summary(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
) -> StoreResult<UsageOverview> {
filter.validate()?;
let requests = request_metrics(&self.pool, range, &filter).await?;
let attempts = attempt_metrics(&self.pool, range, &filter).await?;
let providers = provider_observations(&self.pool, range, &filter).await?;
Ok(UsageOverview {
range,
requests,
attempts,
providers,
})
}
async fn usage_diagnostics(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>> {
usage_diagnostics(&self.pool, range, &filter, dimension).await
}
async fn list_ops_errors(&self, query: OpsErrorQuery) -> StoreResult<OpsErrorPage> {
list_ops_errors(&self.pool, query).await
}
}
#[async_trait]
impl AdminObservabilityStore for PgAdminObservabilityStore {
async fn dashboard_summary(
&self,
range: admin_observability::TimeRange,
) -> AdminStoreResult<admin_observability::DashboardObservation> {
let observation = self
.repository
.dashboard_summary(store_range(range)?)
.await
.map_err(observability_error)?;
admin_dashboard_observation(observation)
}
async fn dashboard_runtime_slots(
&self,
observed_at: DateTime<Utc>,
) -> AdminStoreResult<Option<admin_observability::DashboardRuntimeSlots>> {
let active_account_ids = active_provider_account_ids(&self.repository.pool, observed_at)
.await
.map_err(observability_error)?;
let active_accounts = u64::try_from(active_account_ids.len())
.map_err(|_| observability_error(invalid("active account count overflows u64")))?;
if active_account_ids.is_empty() {
return Ok(Some(admin_observability::DashboardRuntimeSlots {
active_accounts,
used_slots: Some(0),
}));
}
let Some(runtime_signals) = &self.runtime_signals else {
return Ok(None);
};
let signals = match runtime_signals
.credential_runtime_signals(&active_account_ids)
.await
{
Ok(signals) => signals,
Err(_) => {
return Ok(Some(admin_observability::DashboardRuntimeSlots {
active_accounts,
used_slots: None,
}));
}
};
let used_slots = signals.into_iter().fold(0_u64, |total, signal| {
total.saturating_add(u64::from(signal.in_flight))
});
Ok(Some(admin_observability::DashboardRuntimeSlots {
active_accounts,
used_slots: Some(used_slots),
}))
}
async fn dashboard_trend(
&self,
range: admin_observability::TimeRange,
) -> AdminStoreResult<Vec<admin_observability::RequestMetricPoint>> {
self.repository
.dashboard_trend(store_range(range)?)
.await
.map_err(observability_error)?
.into_iter()
.map(admin_request_metric_point)
.collect()
}
async fn usage_trend(
&self,
range: admin_observability::TimeRange,
filter: admin_observability::UsageFilter,
) -> AdminStoreResult<Vec<admin_observability::RequestMetricPoint>> {
self.repository
.usage_trend(store_range(range)?, store_usage_filter(filter))
.await
.map_err(observability_error)?
.into_iter()
.map(admin_request_metric_point)
.collect()
}
async fn usage_calculated_billing_facts(
&self,
range: admin_observability::TimeRange,
filter: admin_observability::UsageFilter,
) -> AdminStoreResult<Vec<admin_observability::UsageCalculatedBillingFact>> {
self.repository
.usage_calculated_billing_facts(store_range(range)?, store_usage_filter(filter))
.await
.map_err(observability_error)?
.into_iter()
.map(admin_calculated_usage_billing_fact)
.collect()
}
async fn list_usage_records(
&self,
query: admin_observability::UsageQuery,
) -> AdminStoreResult<admin_observability::UsagePage> {
let page = self
.repository
.list_usage_records(store_usage_query(query)?)
.await
.map_err(observability_error)?;
admin_usage_page(page)
}
async fn usage_record_detail(
&self,
request_id: &str,
) -> AdminStoreResult<admin_observability::UsageDetail> {
let detail = self
.repository
.usage_record_detail(request_id)
.await
.map_err(observability_error)?;
admin_usage_detail(detail)
}
async fn usage_summary(
&self,
range: admin_observability::TimeRange,
filter: admin_observability::UsageFilter,
) -> AdminStoreResult<admin_observability::UsageOverview> {
let overview = self
.repository
.usage_summary(store_range(range)?, store_usage_filter(filter))
.await
.map_err(observability_error)?;
admin_usage_overview(overview)
}
async fn usage_diagnostics(
&self,
range: admin_observability::TimeRange,
filter: admin_observability::UsageFilter,
dimension: admin_observability::DiagnosticDimension,
) -> AdminStoreResult<Vec<admin_observability::DiagnosticObservation>> {
self.repository
.usage_diagnostics(
store_range(range)?,
store_usage_filter(filter),
store_diagnostic_dimension(dimension),
)
.await
.map_err(observability_error)?
.into_iter()
.map(admin_diagnostic_observation)
.collect()
}
async fn list_ops_errors(
&self,
query: admin_observability::OpsErrorQuery,
) -> AdminStoreResult<admin_observability::OpsErrorPage> {
let page = self
.repository
.list_ops_errors(store_ops_error_query(query)?)
.await
.map_err(observability_error)?;
admin_ops_error_page(page)
}
}
pub(crate) fn observability_error(
error: StoreError,
) -> gateway_admin::ports::store::AdminStoreError {
admin_store_error("observability", error)
}
@@ -0,0 +1,862 @@
//! Store 与 Admin 领域之间的无格式化转换及 row 解析。
use super::*;
pub(crate) fn store_range(
range: admin_observability::TimeRange,
) -> AdminStoreResult<ObservabilityRange> {
ObservabilityRange::new(range.start, range.end).map_err(observability_error)
}
pub(crate) fn store_usage_filter(filter: admin_observability::UsageFilter) -> UsageRecordFilter {
UsageRecordFilter {
client_api_key_ref: filter.client_api_key_ref,
request_id: filter.request_id,
provider_account_ref: filter.provider_account_ref,
operation: filter.operation,
provider_kind: filter.provider_kind,
model: filter.model,
outcome: filter.outcome.map(store_request_outcome),
status_code: filter.status_code,
transport: filter.transport,
attempt_index: filter.attempt_index,
response_id: filter.response_id,
upstream_request_id: filter.upstream_request_id,
search: filter.search,
}
}
pub(crate) fn store_request_outcome(outcome: admin_observability::RequestOutcome) -> String {
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()?,
page: ObservabilityPageNumber::new(query.page.get()).map_err(observability_error)?,
page_size: ObservabilityPageSize::new(query.page_size.get())
.map_err(observability_error)?,
})
}
pub(crate) fn store_ops_error_filter(
filter: admin_observability::OpsErrorFilter,
) -> OpsErrorFilter {
OpsErrorFilter {
client_api_key_ref: filter.client_api_key_ref,
request_id: filter.request_id,
provider_account_ref: filter.provider_account_ref,
provider_kind: filter.provider_kind,
operation: filter.operation,
model: filter.model,
transport: filter.transport,
attempt_index: filter.attempt_index,
response_id: filter.response_id,
upstream_request_id: filter.upstream_request_id,
failure_kind: filter.failure_kind,
status_code: filter.status_code,
search: filter.search,
}
}
pub(crate) fn store_ops_error_query(
query: admin_observability::OpsErrorQuery,
) -> AdminStoreResult<OpsErrorQuery> {
Ok(OpsErrorQuery {
range: store_range(query.range)?,
filter: store_ops_error_filter(query.filter),
cursor: query.cursor.map(store_cursor).transpose()?,
page: ObservabilityPageNumber::new(query.page.get()).map_err(observability_error)?,
page_size: ObservabilityPageSize::new(query.page_size.get())
.map_err(observability_error)?,
})
}
pub(crate) const fn store_diagnostic_dimension(
dimension: admin_observability::DiagnosticDimension,
) -> DiagnosticDimension {
match dimension {
admin_observability::DiagnosticDimension::Provider => DiagnosticDimension::Provider,
admin_observability::DiagnosticDimension::Model => DiagnosticDimension::Model,
admin_observability::DiagnosticDimension::Account => DiagnosticDimension::Account,
admin_observability::DiagnosticDimension::ApiKey => DiagnosticDimension::ApiKey,
admin_observability::DiagnosticDimension::Transport => DiagnosticDimension::Transport,
admin_observability::DiagnosticDimension::Failure => DiagnosticDimension::Failure,
admin_observability::DiagnosticDimension::Status => DiagnosticDimension::Status,
}
}
pub(crate) fn admin_dashboard_observation(
observation: DashboardObservation,
) -> AdminStoreResult<admin_observability::DashboardObservation> {
let DashboardObservation {
range,
requests,
attempts,
provider_accounts,
trend,
account_usage,
recent_requests,
} = observation;
Ok(admin_observability::DashboardObservation {
range: admin_range(range),
requests: admin_request_metrics(requests)?,
attempts: admin_attempt_metrics(attempts)?,
provider_accounts: admin_account_pool_metrics(provider_accounts),
trend: trend
.into_iter()
.map(admin_request_metric_point)
.collect::<AdminStoreResult<_>>()?,
account_usage: account_usage
.into_iter()
.map(admin_dashboard_account_usage)
.collect::<AdminStoreResult<_>>()?,
recent_requests: recent_requests
.into_iter()
.map(admin_usage_record)
.collect::<AdminStoreResult<_>>()?,
})
}
pub(crate) const fn admin_range(range: ObservabilityRange) -> admin_observability::TimeRange {
admin_observability::TimeRange {
start: range.start,
end: range.end,
}
}
pub(crate) fn admin_request_metrics(
metrics: RequestMetrics,
) -> AdminStoreResult<admin_observability::RequestMetrics> {
Ok(admin_observability::RequestMetrics {
request_count: metrics.request_count,
success_count: metrics.success_count,
failure_count: metrics.failure_count,
cancelled_count: metrics.cancelled_count,
incomplete_count: metrics.incomplete_count,
caller_error_count: metrics.caller_error_count,
input_tokens: metrics.input_tokens,
output_tokens: metrics.output_tokens,
cached_tokens: metrics.cached_tokens,
cache_write_tokens: metrics.cache_write_tokens,
reasoning_tokens: metrics.reasoning_tokens,
total_tokens: metrics.total_tokens,
first_token_latency_sum_ms: metrics.first_token_latency_sum,
first_token_latency_count: metrics.first_token_latency_count,
latency_sum_ms: metrics.latency_sum,
latency_count: metrics.latency_count,
min_latency_ms: metrics.min_latency_ms,
max_latency_ms: metrics.max_latency_ms,
latency_percentiles: admin_latency_percentiles(metrics.latency_percentiles)?,
first_token_latency_percentiles: admin_latency_percentiles(
metrics.first_token_latency_percentiles,
)?,
cache_eligible_request_count: metrics.cache_eligible_request_count,
cache_hit_request_count: metrics.cache_hit_request_count,
})
}
pub(crate) fn admin_latency_percentiles(
percentiles: LatencyPercentiles,
) -> AdminStoreResult<admin_observability::LatencyPercentiles> {
Ok(admin_observability::LatencyPercentiles {
p50_ms: percentiles
.p50_ms
.map(|value| admin_observability::PercentileMilliseconds::new(value.as_f64()))
.transpose()
.map_err(|_| invalid_admin_percentile())?,
p95_ms: percentiles
.p95_ms
.map(|value| admin_observability::PercentileMilliseconds::new(value.as_f64()))
.transpose()
.map_err(|_| invalid_admin_percentile())?,
p99_ms: percentiles
.p99_ms
.map(|value| admin_observability::PercentileMilliseconds::new(value.as_f64()))
.transpose()
.map_err(|_| invalid_admin_percentile())?,
})
}
pub(crate) fn invalid_admin_percentile() -> gateway_admin::ports::store::AdminStoreError {
observability_error(StoreError::InvalidData {
entity: "observability latency percentile",
message: "latency percentile is outside the admin contract".to_owned(),
})
}
pub(crate) fn admin_attempt_metrics(
metrics: AttemptMetrics,
) -> AdminStoreResult<admin_observability::AttemptMetrics> {
Ok(admin_observability::AttemptMetrics {
attempt_count: metrics.attempt_count,
success_count: metrics.success_count,
failure_count: metrics.failure_count,
cancelled_count: metrics.cancelled_count,
incomplete_count: metrics.incomplete_count,
rate_limited_count: metrics.rate_limited_count,
auth_failure_count: metrics.auth_failure_count,
provider_5xx_count: metrics.provider_5xx_count,
cost_coverage: admin_cost_coverage(metrics.cost_coverage),
costs: admin_currency_costs(metrics.costs)?,
})
}
pub(crate) const fn admin_cost_coverage(
coverage: CostCoverage,
) -> admin_observability::CostCoverage {
admin_observability::CostCoverage {
provider_reported_count: coverage.provider_reported_count,
calculated_count: coverage.calculated_count,
partial_count: 0,
unavailable_count: coverage.unavailable_count,
not_billable_count: 0,
}
}
pub(crate) fn admin_currency_costs(
costs: Vec<CurrencyCostTotal>,
) -> AdminStoreResult<Vec<admin_observability::CurrencyCost>> {
costs
.into_iter()
.map(|cost| {
Ok(admin_observability::CurrencyCost {
currency: cost.currency,
amount: admin_decimal_amount(cost.amount)?,
})
})
.collect()
}
pub(crate) fn admin_decimal_amount(
amount: DecimalAmount,
) -> AdminStoreResult<admin_observability::DecimalAmount> {
admin_observability::DecimalAmount::from_str(amount.as_str()).map_err(|_| {
observability_error(StoreError::InvalidData {
entity: "observability decimal amount",
message: "amount is outside the admin numeric contract".to_owned(),
})
})
}
pub(crate) fn admin_optional_decimal_amount(
amount: Option<DecimalAmount>,
) -> AdminStoreResult<Option<admin_observability::DecimalAmount>> {
amount.map(admin_decimal_amount).transpose()
}
pub(crate) const fn admin_account_pool_metrics(
metrics: ProviderAccountMetrics,
) -> admin_observability::AccountPoolMetrics {
admin_observability::AccountPoolMetrics {
total: metrics.total,
enabled: metrics.enabled,
unavailable: metrics.unavailable,
active: metrics.active,
expired: metrics.expired,
quota_exhausted: metrics.quota_exhausted,
refreshing: None,
disabled: metrics.disabled,
banned: metrics.banned,
}
}
pub(crate) fn admin_dashboard_account_usage(
usage: ProviderAccountUsageObservation,
) -> AdminStoreResult<admin_observability::DashboardAccountUsage> {
Ok(admin_observability::DashboardAccountUsage {
account_id: usage.account_id,
provider_kind: usage.provider_kind,
authentication_kind: usage.authentication_kind,
name: usage.name,
email: usage.email,
plan_type: usage.plan_type,
enabled: usage.enabled,
availability: usage.availability,
request_count: usage.request_count,
success_count: usage.success_count,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
cache_write_tokens: usage.cache_write_tokens,
reasoning_tokens: usage.reasoning_tokens,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
image_request_count: usage.image_request_count,
image_request_failed_count: usage.image_request_failed_count,
total_tokens: usage.total_tokens,
cost_coverage: admin_cost_coverage(usage.cost_coverage),
costs: admin_currency_costs(usage.costs)?,
last_used_at: usage.last_used_at,
request_buckets: usage
.request_buckets
.into_iter()
.map(
|bucket| admin_observability::DashboardAccountRequestBucket {
bucket_start: bucket.bucket_start,
request_count: bucket.request_count,
},
)
.collect(),
quota_used_percent: None,
models: usage
.models
.into_iter()
.map(admin_dashboard_account_model_usage)
.collect::<AdminStoreResult<_>>()?,
})
}
pub(crate) fn admin_dashboard_account_model_usage(
usage: ProviderAccountModelUsageObservation,
) -> AdminStoreResult<admin_observability::DashboardAccountModelUsage> {
Ok(admin_observability::DashboardAccountModelUsage {
model: usage.model,
request_count: usage.request_count,
success_count: usage.success_count,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
cache_write_tokens: usage.cache_write_tokens,
reasoning_tokens: usage.reasoning_tokens,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
image_request_count: usage.image_request_count,
image_request_failed_count: usage.image_request_failed_count,
total_tokens: usage.total_tokens,
cost_coverage: admin_cost_coverage(usage.cost_coverage),
costs: admin_currency_costs(usage.costs)?,
last_used_at: usage.last_used_at,
})
}
pub(crate) fn admin_request_metric_point(
point: RequestMetricPoint,
) -> AdminStoreResult<admin_observability::RequestMetricPoint> {
Ok(admin_observability::RequestMetricPoint {
bucket_start: point.bucket_start,
granularity: admin_granularity(point.granularity),
metrics: admin_request_metrics(point.metrics)?,
cost_coverage: admin_cost_coverage(point.cost_coverage),
costs: admin_currency_costs(point.costs)?,
})
}
pub(crate) fn admin_calculated_usage_billing_fact(
fact: CalculatedUsageBillingFact,
) -> AdminStoreResult<admin_observability::UsageCalculatedBillingFact> {
Ok(admin_observability::UsageCalculatedBillingFact {
bucket_start: fact.bucket_start,
provider_kind: fact.provider_kind,
upstream_model_id: fact.upstream_model_id,
service_tier: fact.service_tier,
input_tokens: fact.input_tokens,
output_tokens: fact.output_tokens,
cached_tokens: fact.cached_tokens,
cache_write_tokens: fact.cache_write_tokens,
total: admin_observability::CurrencyCost {
currency: fact.total.currency,
amount: admin_decimal_amount(fact.total.amount)?,
},
})
}
pub(crate) const fn admin_granularity(
granularity: ObservationGranularity,
) -> admin_observability::Granularity {
match granularity {
ObservationGranularity::FifteenMinutes => admin_observability::Granularity::FifteenMinutes,
ObservationGranularity::Hour => admin_observability::Granularity::Hour,
ObservationGranularity::Day => admin_observability::Granularity::Day,
}
}
pub(crate) fn admin_usage_page(
page: UsageRecordPage,
) -> AdminStoreResult<admin_observability::UsagePage> {
Ok(admin_observability::UsagePage {
items: page
.items
.into_iter()
.map(admin_usage_record)
.collect::<AdminStoreResult<_>>()?,
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_record(
record: UsageRecord,
) -> AdminStoreResult<admin_observability::UsageRecord> {
let billing = match (&record.cost_amount, &record.cost_currency) {
(Some(amount), Some(currency)) => Some(admin_observability::UsageBilling::Total {
source: record.cost_source.clone(),
total: admin_observability::CurrencyCost {
currency: currency.clone(),
amount: admin_decimal_amount(amount.clone())?,
},
}),
(None, None) => None,
_ => {
return Err(observability_error(StoreError::InvalidData {
entity: "observability request billing",
message: "cost amount and currency must be present together".to_owned(),
}));
}
};
Ok(admin_observability::UsageRecord {
id: record.id,
client_api_key_ref: record.client_api_key_ref,
config_revision: record.config_revision,
protocol: record.protocol,
operation: record.operation,
endpoint: record.endpoint,
client_transport: record.client_transport,
requested_model_id: record.requested_model_id,
provider_kind: record.provider_kind,
provider_account_ref: record.provider_account_ref,
provider_account_name: record.provider_account_name,
provider_account_email: record.provider_account_email,
provider_account_authentication_kind: record.provider_account_authentication_kind,
upstream_model_id: record.upstream_model_id,
upstream_transport: record.upstream_transport,
http_version: record.http_version,
websocket_pool: record.websocket_pool,
service_tier: record.service_tier,
provider_metadata_json: record.provider_metadata_json,
attempt_count: record.attempt_count,
upstream_send_state: record.upstream_send_state,
downstream_committed_at: record.downstream_committed_at,
outcome: admin_request_outcome(&record.outcome)?,
client_status_code: record.client_status_code,
upstream_status_code: record.upstream_status_code,
client_response_id: record.client_response_id,
upstream_request_id: record.upstream_request_id,
upstream_response_id: record.upstream_response_id,
error_kind: record.error_kind,
provider_error_code: record.provider_error_code,
error_message: record.error_message,
retry_after_ms: record.retry_after_ms,
input_tokens: record.input_tokens,
output_tokens: record.output_tokens,
cached_tokens: record.cached_tokens,
cache_write_tokens: record.cache_write_tokens,
reasoning_tokens: record.reasoning_tokens,
image_input_tokens: record.image_input_tokens,
image_output_tokens: record.image_output_tokens,
total_tokens: record.total_tokens,
cost_source: record.cost_source,
cost_amount: admin_optional_decimal_amount(record.cost_amount)?,
cost_currency: record.cost_currency,
billing,
transport_decision_wait_ms: record.transport_decision_wait_ms,
connect_ms: record.connect_ms,
headers_ms: record.headers_ms,
first_event_ms: record.first_event_ms,
first_reasoning_ms: record.first_reasoning_ms,
first_text_ms: record.first_text_ms,
first_token_ms: record.first_token_ms,
provider_processing_ms: record.provider_processing_ms,
latency_ms: record.latency_ms,
client_ip: record.client_ip,
user_agent: record.user_agent,
reasoning_effort: record.reasoning_effort,
reasoning_preset: record.reasoning_preset,
request_kind: record.request_kind,
subagent_kind: record.subagent_kind,
compact: record.compact,
image_generation_requested: record.image_generation_requested,
image_generation_succeeded: record.image_generation_succeeded,
started_at: record.started_at,
deadline_at: record.deadline_at,
completed_at: record.completed_at,
})
}
pub(crate) fn admin_request_outcome(
outcome: &str,
) -> AdminStoreResult<admin_observability::RequestOutcome> {
admin_observability::RequestOutcome::new(outcome.to_owned()).map_err(|_| {
observability_error(StoreError::InvalidData {
entity: "observability request outcome",
message: "invalid request outcome".to_owned(),
})
})
}
pub(crate) fn admin_usage_detail(
detail: UsageRecordDetail,
) -> AdminStoreResult<admin_observability::UsageDetail> {
Ok(admin_observability::UsageDetail {
request: admin_usage_record(detail.request)?,
attempts: detail
.attempts
.into_iter()
.map(admin_usage_attempt)
.collect::<AdminStoreResult<_>>()?,
})
}
pub(crate) fn admin_usage_attempt(
attempt: UsageAttemptObservation,
) -> AdminStoreResult<admin_observability::UsageAttempt> {
Ok(admin_observability::UsageAttempt {
source: attempt.source,
id: attempt.id,
attempt_index: attempt.attempt_index,
component: attempt.component,
operation: attempt.operation,
provider_kind: attempt.provider_kind,
provider_account_ref: attempt.provider_account_ref,
provider_account_name: attempt.provider_account_name,
provider_account_email: attempt.provider_account_email,
provider_account_authentication_kind: attempt.provider_account_authentication_kind,
upstream_model_id: attempt.upstream_model_id,
upstream_transport: attempt.upstream_transport,
upstream_send_state: attempt.upstream_send_state,
outcome: admin_request_outcome(&attempt.outcome)?,
downstream_committed: attempt.downstream_committed,
status_code: attempt.status_code,
provider_error_code: attempt.provider_error_code,
failure_kind: attempt.failure_kind,
retry_after_ms: attempt.retry_after_ms,
upstream_request_id: attempt.upstream_request_id,
latency_ms: attempt.latency_ms,
message: attempt.message,
input_tokens: attempt.input_tokens,
output_tokens: attempt.output_tokens,
cached_tokens: attempt.cached_tokens,
cache_write_tokens: attempt.cache_write_tokens,
reasoning_tokens: attempt.reasoning_tokens,
total_tokens: attempt.total_tokens,
cost_source: attempt.cost_source,
cost_amount: admin_optional_decimal_amount(attempt.cost_amount)?,
cost_currency: attempt.cost_currency,
occurred_at: attempt.occurred_at,
})
}
pub(crate) fn admin_usage_overview(
overview: UsageOverview,
) -> AdminStoreResult<admin_observability::UsageOverview> {
Ok(admin_observability::UsageOverview {
range: admin_range(overview.range),
requests: admin_request_metrics(overview.requests)?,
attempts: admin_attempt_metrics(overview.attempts)?,
providers: overview
.providers
.into_iter()
.map(admin_provider_observation)
.collect(),
})
}
pub(crate) fn admin_provider_observation(
observation: ProviderObservation,
) -> admin_observability::ProviderObservation {
admin_observability::ProviderObservation {
provider_kind: observation.provider_kind,
request_count: observation.request_count,
attempt_count: observation.attempt_count,
failure_count: observation.failure_count,
total_tokens: observation.total_tokens,
}
}
pub(crate) fn admin_diagnostic_observation(
observation: DiagnosticObservation,
) -> AdminStoreResult<admin_observability::DiagnosticObservation> {
Ok(admin_observability::DiagnosticObservation {
key: observation.key,
name: observation.name,
request_count: observation.request_count,
success_count: observation.success_count,
failure_count: observation.failure_count,
attempt_count: observation.attempt_count,
total_tokens: observation.total_tokens,
average_latency_ms: observation.average_latency_ms,
latency_p95_ms: observation.latency_p95_ms,
cost_coverage: admin_cost_coverage(observation.cost_coverage),
costs: admin_currency_costs(observation.costs)?,
})
}
pub(crate) fn admin_ops_error_page(
page: OpsErrorPage,
) -> AdminStoreResult<admin_observability::OpsErrorPage> {
Ok(admin_observability::OpsErrorPage {
items: page.items.into_iter().map(admin_ops_error).collect(),
total: page.total,
next_cursor: page.next_cursor.map(admin_cursor),
})
}
pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
admin_observability::OpsError {
source: error.source,
event_id: error.event_id,
request_id: error.request_id,
attempt_index: error.attempt_index,
client_api_key_ref: error.client_api_key_ref,
component: error.component,
operation: error.operation,
endpoint: error.endpoint,
provider_kind: error.provider_kind,
provider_account_ref: error.provider_account_ref,
provider_account_name: error.provider_account_name,
provider_account_email: error.provider_account_email,
provider_account_authentication_kind: error.provider_account_authentication_kind,
upstream_model_id: error.upstream_model_id,
upstream_transport: error.upstream_transport,
failure_kind: error.failure_kind,
client_status_code: error.client_status_code,
upstream_status_code: error.upstream_status_code,
provider_error_code: error.provider_error_code,
client_response_id: error.client_response_id,
upstream_request_id: error.upstream_request_id,
latency_ms: error.latency_ms,
message: error.message,
occurrence_count: error.occurrence_count,
occurred_at: error.occurred_at,
stable_sort_id: error.stable_sort_id,
}
}
pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<UsageRecord> {
Ok(UsageRecord {
id: get(row, "id")?,
client_api_key_ref: get(row, "client_api_key_ref")?,
config_revision: unsigned(row, "config_revision")?,
protocol: get(row, "protocol")?,
operation: get(row, "operation")?,
endpoint: get(row, "endpoint")?,
client_transport: get(row, "client_transport")?,
requested_model_id: get(row, "requested_model_id")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
http_version: get(row, "http_version")?,
websocket_pool: get(row, "websocket_pool")?,
service_tier: get(row, "service_tier")?,
provider_metadata_json: get::<Option<serde_json::Value>>(row, "provider_observation_json")?
.map(|value| serde_json::to_string(&value))
.transpose()
.map_err(|_| postgres_unavailable("encode provider observation"))?,
attempt_count: to_u32(get(row, "attempt_count")?)?,
upstream_send_state: get(row, "upstream_send_state")?,
downstream_committed_at: get(row, "downstream_committed_at")?,
outcome: get(row, "outcome")?,
client_status_code: optional_status(row, "client_status_code")?,
upstream_status_code: optional_status(row, "upstream_status_code")?,
client_response_id: opaque_response_id(row, "client_response_id")?,
upstream_request_id: get(row, "upstream_request_id")?,
upstream_response_id: opaque_response_id(row, "upstream_response_id")?,
error_kind: get(row, "error_kind")?,
provider_error_code: get(row, "provider_error_code")?,
error_message: get(row, "error_message")?,
retry_after_ms: optional_unsigned(row, "retry_after_ms")?,
input_tokens: optional_unsigned(row, "input_tokens")?,
output_tokens: optional_unsigned(row, "output_tokens")?,
cached_tokens: optional_unsigned(row, "cached_tokens")?,
cache_write_tokens: optional_unsigned(row, "cache_write_tokens")?,
reasoning_tokens: optional_unsigned(row, "reasoning_tokens")?,
image_input_tokens: optional_unsigned(row, "image_input_tokens")?,
image_output_tokens: optional_unsigned(row, "image_output_tokens")?,
total_tokens: optional_unsigned(row, "total_tokens")?,
cost_source: get(row, "cost_source")?,
cost_amount: optional_decimal(row, "cost_amount")?,
cost_currency: get(row, "cost_currency")?,
transport_decision_wait_ms: optional_unsigned(row, "transport_decision_wait_ms")?,
connect_ms: optional_unsigned(row, "connect_ms")?,
headers_ms: optional_unsigned(row, "headers_ms")?,
first_event_ms: optional_unsigned(row, "first_event_ms")?,
first_reasoning_ms: optional_unsigned(row, "first_reasoning_ms")?,
first_text_ms: optional_unsigned(row, "first_text_ms")?,
first_token_ms: optional_unsigned(row, "first_token_ms")?,
provider_processing_ms: optional_unsigned(row, "provider_processing_ms")?,
latency_ms: optional_unsigned(row, "latency_ms")?,
client_ip: get(row, "client_ip")?,
user_agent: get(row, "user_agent")?,
reasoning_effort: get(row, "reasoning_effort")?,
reasoning_preset: get(row, "reasoning_preset")?,
request_kind: get(row, "request_kind")?,
subagent_kind: get(row, "subagent_kind")?,
compact: get(row, "compact")?,
image_generation_requested: get(row, "image_generation_requested")?,
image_generation_succeeded: get(row, "image_generation_succeeded")?,
started_at: get(row, "started_at")?,
deadline_at: get(row, "deadline_at")?,
completed_at: get(row, "completed_at")?,
})
}
pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<OpsErrorRecord> {
Ok(OpsErrorRecord {
source: get(row, "source")?,
event_id: get(row, "event_id")?,
request_id: get(row, "request_id")?,
attempt_index: get::<Option<i32>>(row, "attempt_index")?
.map(to_u32)
.transpose()?,
client_api_key_ref: get(row, "client_api_key_ref")?,
component: get(row, "component")?,
operation: get(row, "operation")?,
endpoint: get(row, "endpoint")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
failure_kind: get(row, "failure_kind")?,
client_status_code: optional_status(row, "client_status_code")?,
upstream_status_code: optional_status(row, "upstream_status_code")?,
provider_error_code: get(row, "provider_error_code")?,
client_response_id: opaque_response_id(row, "client_response_id")?,
upstream_request_id: get(row, "upstream_request_id")?,
latency_ms: optional_unsigned(row, "latency_ms")?,
message: get(row, "message")?,
occurrence_count: to_u32(get(row, "occurrence_count")?)?,
occurred_at: get(row, "occurred_at")?,
stable_sort_id: get(row, "stable_sort_id")?,
})
}
pub(crate) fn cost_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<CurrencyCostTotal> {
Ok(CurrencyCostTotal {
currency: get(row, "cost_currency")?,
amount: DecimalAmount::from_str(&get::<String>(row, "amount")?)?,
})
}
pub(crate) fn calculated_usage_billing_fact_from_row(
row: &sqlx::postgres::PgRow,
) -> StoreResult<CalculatedUsageBillingFact> {
Ok(CalculatedUsageBillingFact {
bucket_start: get(row, "bucket_start")?,
provider_kind: get(row, "provider_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
service_tier: get(row, "service_tier")?,
input_tokens: optional_unsigned(row, "input_tokens")?,
output_tokens: optional_unsigned(row, "output_tokens")?,
cached_tokens: optional_unsigned(row, "cached_tokens")?,
cache_write_tokens: optional_unsigned(row, "cache_write_tokens")?,
total: cost_from_row(row)?,
})
}
pub(crate) fn optional_decimal(
row: &sqlx::postgres::PgRow,
column: &'static str,
) -> StoreResult<Option<DecimalAmount>> {
get::<Option<String>>(row, column)?
.map(|value| DecimalAmount::from_str(&value))
.transpose()
}
pub(crate) fn opaque_response_id(
row: &sqlx::postgres::PgRow,
column: &'static str,
) -> StoreResult<Option<String>> {
get::<Option<Vec<u8>>>(row, column)?
.map(String::from_utf8)
.transpose()
.map_err(|_| invalid(column))
}
pub(crate) fn validate_account_ids(account_ids: &[String]) -> StoreResult<()> {
let mut unique = BTreeSet::new();
for account_id in account_ids {
validate_text(account_id, MAX_FILTER_BYTES, "provider account ID")?;
if !unique.insert(account_id.as_str()) {
return Err(invalid("account usage query contains duplicate IDs"));
}
}
Ok(())
}
pub(crate) fn validate_optional_text(
value: Option<&str>,
max_bytes: usize,
field: &'static str,
) -> StoreResult<()> {
value.map_or(Ok(()), |value| validate_text(value, max_bytes, field))
}
pub(crate) fn validate_text(value: &str, max_bytes: usize, field: &'static str) -> StoreResult<()> {
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
return Err(invalid(&format!("{field} is invalid")));
}
Ok(())
}
pub(crate) fn get<'r, T>(row: &'r sqlx::postgres::PgRow, column: &'static str) -> StoreResult<T>
where
T: sqlx::Decode<'r, Postgres> + sqlx::Type<Postgres>,
{
row.try_get(column).map_err(|_| invalid(column))
}
pub(crate) fn unsigned(row: &sqlx::postgres::PgRow, column: &'static str) -> StoreResult<u64> {
to_u64(get(row, column)?)
}
pub(crate) fn optional_unsigned(
row: &sqlx::postgres::PgRow,
column: &'static str,
) -> StoreResult<Option<u64>> {
get::<Option<i64>>(row, column)?.map(to_u64).transpose()
}
pub(crate) fn optional_status(
row: &sqlx::postgres::PgRow,
column: &'static str,
) -> StoreResult<Option<u16>> {
get::<Option<i32>>(row, column)?
.map(|value| {
u16::try_from(value)
.ok()
.filter(|value| (100..=599).contains(value))
.ok_or_else(|| invalid("status code is outside its supported range"))
})
.transpose()
}
pub(crate) fn to_u64(value: i64) -> StoreResult<u64> {
u64::try_from(value).map_err(|_| invalid("numeric observation is negative"))
}
pub(crate) fn to_u32(value: i32) -> StoreResult<u32> {
u32::try_from(value).map_err(|_| invalid("integer observation is negative"))
}
pub(crate) fn invalid(message: &str) -> StoreError {
StoreError::InvalidData {
entity: "observability",
message: message.to_owned(),
}
}
@@ -0,0 +1,29 @@
//! 从 `model_requests`、`ops_events` 与账号公共投影读取观测事实。
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
str::FromStr,
};
use async_trait::async_trait;
use chrono::{DateTime, TimeDelta, Utc};
use gateway_admin::{
model::observability as admin_observability,
ports::store::{AdminStoreResult, ObservabilityStore as AdminObservabilityStore},
};
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
use crate::{
DecimalAmount, StoreError, StoreResult, admin_store_error, postgres_unavailable,
require_nonempty,
};
mod admin_adapter;
mod mapping;
mod model;
mod queries;
pub use admin_adapter::*;
pub(crate) use mapping::*;
pub use model::*;
pub(crate) use queries::*;
@@ -0,0 +1,713 @@
//! 查询模型、校验与观测端口契约。
use super::*;
pub(crate) const MAX_PAGE_SIZE: u16 = 100;
pub(crate) const MAX_FILTER_BYTES: usize = 256;
pub(crate) const MAX_SEARCH_BYTES: usize = 512;
pub(crate) const MAX_ACCOUNT_IDS: usize = 200;
/// 概览卡只展示最近使用的四个账号;完整账号用量由账号管理页单独查询。
pub(crate) const DASHBOARD_ACCOUNT_LIMIT: u16 = 4;
pub(crate) const DIAGNOSTIC_LIMIT: i64 = 100;
pub(crate) const ACCOUNT_USAGE_TIMELINE_HOURS: i64 = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObservabilityRange {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
impl ObservabilityRange {
pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> StoreResult<Self> {
if end.signed_duration_since(start) <= TimeDelta::zero() {
return Err(invalid("time range must be positive"));
}
Ok(Self { start, end })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObservabilityPageSize(u16);
impl ObservabilityPageSize {
pub fn new(value: u16) -> StoreResult<Self> {
if value == 0 || value > MAX_PAGE_SIZE {
return Err(invalid("page size must be between 1 and 100"));
}
Ok(Self(value))
}
#[must_use]
pub const fn get(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObservabilityPageNumber(u32);
impl ObservabilityPageNumber {
pub fn new(value: u32) -> StoreResult<Self> {
if value == 0 {
return Err(invalid("page number must be positive"));
}
Ok(Self(value))
}
#[must_use]
pub const fn get(self) -> u32 {
self.0
}
}
#[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,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageRecordFilter {
pub client_api_key_ref: Option<String>,
pub request_id: Option<String>,
pub provider_account_ref: Option<String>,
pub operation: Option<String>,
pub provider_kind: Option<String>,
pub model: Option<String>,
pub outcome: Option<String>,
pub status_code: Option<u16>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
}
impl UsageRecordFilter {
pub fn validate(&self) -> StoreResult<()> {
for (value, field) in [
(self.client_api_key_ref.as_deref(), "client API key filter"),
(self.request_id.as_deref(), "request ID filter"),
(
self.provider_account_ref.as_deref(),
"provider account filter",
),
(self.operation.as_deref(), "operation filter"),
(self.provider_kind.as_deref(), "provider filter"),
(self.model.as_deref(), "model filter"),
(self.outcome.as_deref(), "outcome filter"),
(self.transport.as_deref(), "transport filter"),
(
self.upstream_request_id.as_deref(),
"upstream request ID filter",
),
] {
validate_optional_text(value, MAX_FILTER_BYTES, field)?;
}
validate_optional_text(self.search.as_deref(), MAX_SEARCH_BYTES, "search filter")?;
if self
.status_code
.is_some_and(|status| !(100..=599).contains(&status))
{
return Err(invalid("status code filter must be between 100 and 599"));
}
if self
.attempt_index
.is_some_and(|index| index == 0 || i32::try_from(index).is_err())
{
return Err(invalid("attempt index filter is out of range"));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecordQuery {
pub range: ObservabilityRange,
pub filter: UsageRecordFilter,
pub cursor: Option<ObservabilityCursor>,
pub page: ObservabilityPageNumber,
pub page_size: ObservabilityPageSize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OpsErrorFilter {
pub client_api_key_ref: Option<String>,
pub request_id: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_kind: Option<String>,
pub operation: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub failure_kind: Option<String>,
pub status_code: Option<u16>,
pub search: Option<String>,
}
impl OpsErrorFilter {
pub fn validate(&self) -> StoreResult<()> {
for (value, field) in [
(self.client_api_key_ref.as_deref(), "client API key filter"),
(self.request_id.as_deref(), "request ID filter"),
(
self.provider_account_ref.as_deref(),
"provider account filter",
),
(self.provider_kind.as_deref(), "provider filter"),
(self.operation.as_deref(), "operation filter"),
(self.model.as_deref(), "model filter"),
(self.transport.as_deref(), "transport filter"),
(
self.upstream_request_id.as_deref(),
"upstream request ID filter",
),
(self.failure_kind.as_deref(), "failure kind filter"),
] {
validate_optional_text(value, MAX_FILTER_BYTES, field)?;
}
validate_optional_text(self.search.as_deref(), MAX_SEARCH_BYTES, "search filter")?;
if self
.status_code
.is_some_and(|status| !(100..=599).contains(&status))
{
return Err(invalid("status code filter must be between 100 and 599"));
}
if self
.attempt_index
.is_some_and(|index| index == 0 || i32::try_from(index).is_err())
{
return Err(invalid("attempt index filter is out of range"));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorQuery {
pub range: ObservabilityRange,
pub filter: OpsErrorFilter,
pub cursor: Option<ObservabilityCursor>,
pub page: ObservabilityPageNumber,
pub page_size: ObservabilityPageSize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticDimension {
Provider,
Model,
Account,
ApiKey,
Transport,
Failure,
Status,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurrencyCostTotal {
pub currency: String,
pub amount: DecimalAmount,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CostCoverage {
pub provider_reported_count: u64,
pub calculated_count: u64,
pub unavailable_count: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestMetrics {
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub caller_error_count: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub cache_write_tokens: u64,
pub reasoning_tokens: u64,
pub total_tokens: u64,
pub first_token_latency_sum: u64,
pub first_token_latency_count: u64,
pub latency_sum: u64,
pub latency_count: u64,
pub max_latency_ms: Option<u64>,
pub min_latency_ms: Option<u64>,
/// 分母:`input_tokens is not null`,即上游确实报告过 input token 事实的请求。
pub cache_eligible_request_count: u64,
/// 分子:分母集合中 `cached_tokens > 0` 的请求。
pub cache_hit_request_count: u64,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
}
impl RequestMetrics {
/// 请求级 cache hit rate;没有 input token 事实时返回 `None`。
#[must_use]
pub fn cache_hit_request_rate(&self) -> Option<f64> {
(self.cache_eligible_request_count > 0)
.then(|| self.cache_hit_request_count as f64 / self.cache_eligible_request_count as f64)
}
}
/// PostgreSQL `percentile_cont` 的非负、有限毫秒值;bits 保留插值小数且可安全比较。
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct PercentileMilliseconds(u64);
impl PercentileMilliseconds {
pub(crate) fn new(value: f64) -> StoreResult<Self> {
if !value.is_finite() || value < 0.0 {
return Err(postgres_unavailable("decode latency percentile"));
}
Ok(Self(value.to_bits()))
}
#[must_use]
pub const fn as_f64(self) -> f64 {
f64::from_bits(self.0)
}
}
impl std::fmt::Debug for PercentileMilliseconds {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_f64().fmt(formatter)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LatencyPercentiles {
pub p50_ms: Option<PercentileMilliseconds>,
pub p95_ms: Option<PercentileMilliseconds>,
pub p99_ms: Option<PercentileMilliseconds>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttemptMetrics {
pub attempt_count: u64,
pub success_count: u64,
pub failure_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub rate_limited_count: u64,
pub auth_failure_count: u64,
pub provider_5xx_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObservationGranularity {
FifteenMinutes,
Hour,
Day,
}
impl ObservationGranularity {
#[must_use]
pub const fn seconds(self) -> i64 {
match self {
Self::FifteenMinutes => 15 * 60,
Self::Hour => 60 * 60,
Self::Day => 24 * 60 * 60,
}
}
pub(crate) const fn sql_interval(self) -> &'static str {
match self {
Self::FifteenMinutes => "15 minutes",
Self::Hour => "1 hour",
Self::Day => "1 day",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestMetricPoint {
pub bucket_start: DateTime<Utc>,
pub granularity: ObservationGranularity,
pub metrics: RequestMetrics,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
}
/// 已完整交付且由 Provider 计算费用的请求事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedUsageBillingFact {
pub bucket_start: DateTime<Utc>,
pub provider_kind: String,
pub upstream_model_id: String,
pub service_tier: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub total: CurrencyCostTotal,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderAccountMetrics {
pub total: u64,
pub enabled: u64,
pub unavailable: u64,
pub active: u64,
pub expired: u64,
pub quota_exhausted: u64,
pub disabled: u64,
pub banned: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountUsageObservation {
pub account_id: String,
pub provider_kind: String,
pub authentication_kind: String,
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
pub enabled: bool,
pub availability: String,
pub request_count: u64,
pub success_count: u64,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub image_input_tokens: Option<u64>,
pub image_output_tokens: Option<u64>,
pub image_request_count: u64,
pub image_request_failed_count: u64,
pub total_tokens: Option<u64>,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
pub last_used_at: Option<DateTime<Utc>>,
pub request_buckets: Vec<ProviderAccountRequestBucket>,
pub models: Vec<ProviderAccountModelUsageObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountModelUsageObservation {
pub model: String,
pub request_count: u64,
pub success_count: u64,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub image_input_tokens: Option<u64>,
pub image_output_tokens: Option<u64>,
pub image_request_count: u64,
pub image_request_failed_count: u64,
pub total_tokens: Option<u64>,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
pub last_used_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountUsageQuery {
pub range: ObservabilityRange,
pub account_ids: Option<Vec<String>>,
pub limit: u16,
pub(crate) include_hourly_request_buckets: bool,
}
impl ProviderAccountUsageQuery {
pub fn for_accounts(range: ObservabilityRange, account_ids: Vec<String>) -> StoreResult<Self> {
if account_ids.is_empty() || account_ids.len() > MAX_ACCOUNT_IDS {
return Err(invalid(
"account usage query requires between 1 and 200 IDs",
));
}
validate_account_ids(&account_ids)?;
Ok(Self {
range,
limit: u16::try_from(account_ids.len())
.map_err(|_| invalid("account usage query is too large"))?,
account_ids: Some(account_ids),
include_hourly_request_buckets: false,
})
}
pub fn recent(range: ObservabilityRange, limit: u16) -> StoreResult<Self> {
if limit == 0 || usize::from(limit) > MAX_ACCOUNT_IDS {
return Err(invalid("account usage limit must be between 1 and 200"));
}
Ok(Self {
range,
account_ids: None,
limit,
include_hourly_request_buckets: false,
})
}
pub fn with_hourly_request_buckets(mut self) -> StoreResult<Self> {
if self.range.end.signed_duration_since(self.range.start)
> TimeDelta::hours(ACCOUNT_USAGE_TIMELINE_HOURS)
{
return Err(invalid("account request timeline cannot exceed 24 hours"));
}
self.include_hourly_request_buckets = true;
Ok(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardObservation {
pub range: ObservabilityRange,
pub requests: RequestMetrics,
pub attempts: AttemptMetrics,
pub provider_accounts: ProviderAccountMetrics,
pub trend: Vec<RequestMetricPoint>,
pub account_usage: Vec<ProviderAccountUsageObservation>,
pub recent_requests: Vec<UsageRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecord {
pub id: String,
pub client_api_key_ref: String,
pub config_revision: u64,
pub protocol: String,
pub operation: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: String,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub service_tier: Option<String>,
pub provider_metadata_json: Option<String>,
pub attempt_count: u32,
pub upstream_send_state: String,
pub downstream_committed_at: Option<DateTime<Utc>>,
pub outcome: String,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub client_response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub upstream_response_id: Option<String>,
pub error_kind: Option<String>,
pub provider_error_code: Option<String>,
pub error_message: Option<String>,
pub retry_after_ms: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub image_input_tokens: Option<u64>,
pub image_output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub cost_source: String,
pub cost_amount: Option<DecimalAmount>,
pub cost_currency: Option<String>,
pub transport_decision_wait_ms: Option<u64>,
pub connect_ms: Option<u64>,
pub headers_ms: Option<u64>,
pub first_event_ms: Option<u64>,
pub first_reasoning_ms: Option<u64>,
pub first_text_ms: Option<u64>,
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub image_generation_requested: bool,
pub image_generation_succeeded: Option<bool>,
pub started_at: DateTime<Utc>,
pub deadline_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecordPage {
pub items: Vec<UsageRecord>,
pub total: u64,
pub next_cursor: Option<ObservabilityCursor>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageAttemptObservation {
pub source: String,
pub id: String,
pub attempt_index: u32,
pub component: String,
pub operation: String,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub upstream_send_state: Option<String>,
pub outcome: String,
pub downstream_committed: bool,
pub status_code: Option<u16>,
pub provider_error_code: Option<String>,
pub failure_kind: Option<String>,
pub retry_after_ms: Option<u64>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub message: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cache_write_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub cost_source: Option<String>,
pub cost_amount: Option<DecimalAmount>,
pub cost_currency: Option<String>,
pub occurred_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecordDetail {
pub request: UsageRecord,
pub attempts: Vec<UsageAttemptObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderObservation {
pub provider_kind: String,
pub request_count: u64,
pub attempt_count: u64,
pub failure_count: u64,
pub total_tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageOverview {
pub range: ObservabilityRange,
pub requests: RequestMetrics,
pub attempts: AttemptMetrics,
pub providers: Vec<ProviderObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
pub attempt_count: u64,
pub total_tokens: u64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorRecord {
pub source: String,
pub event_id: String,
pub request_id: Option<String>,
pub attempt_index: Option<u32>,
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub endpoint: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub failure_kind: String,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub provider_error_code: Option<String>,
pub client_response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub message: String,
pub occurrence_count: u32,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorPage {
pub items: Vec<OpsErrorRecord>,
pub total: u64,
pub next_cursor: Option<ObservabilityCursor>,
}
#[async_trait]
pub trait ObservabilityRepository: Send + Sync {
async fn dashboard_summary(
&self,
range: ObservabilityRange,
) -> StoreResult<DashboardObservation>;
async fn dashboard_trend(
&self,
range: ObservabilityRange,
) -> StoreResult<Vec<RequestMetricPoint>>;
async fn usage_trend(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
) -> StoreResult<Vec<RequestMetricPoint>>;
async fn usage_calculated_billing_facts(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
) -> StoreResult<Vec<CalculatedUsageBillingFact>>;
async fn provider_account_usage(
&self,
query: ProviderAccountUsageQuery,
) -> StoreResult<Vec<ProviderAccountUsageObservation>>;
async fn list_usage_records(&self, query: UsageRecordQuery) -> StoreResult<UsageRecordPage>;
async fn usage_record_detail(&self, request_id: &str) -> StoreResult<UsageRecordDetail>;
async fn usage_summary(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
) -> StoreResult<UsageOverview>;
async fn usage_diagnostics(
&self,
range: ObservabilityRange,
filter: UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> StoreResult<OpsErrorPage>;
}
@@ -0,0 +1,336 @@
//! 账号用量查询族。
use super::super::*;
pub(crate) async fn provider_account_usage(
pool: &PgPool,
query: ProviderAccountUsageQuery,
) -> StoreResult<Vec<ProviderAccountUsageObservation>> {
if let Some(account_ids) = &query.account_ids {
validate_account_ids(account_ids)?;
}
if query.limit == 0 || usize::from(query.limit) > MAX_ACCOUNT_IDS {
return Err(invalid("account usage limit must be between 1 and 200"));
}
let mut statement = QueryBuilder::<Postgres>::new(
"select pa.id, pa.provider_kind, pa.authentication_kind, pa.name, pa.email,
pa.plan_type, pa.enabled, pa.availability,
count(mr.id)::bigint as request_count,
count(mr.id) filter (where mr.outcome = 'succeeded')::bigint as success_count,
sum(mr.input_tokens)::bigint as input_tokens,
sum(mr.output_tokens)::bigint as output_tokens,
sum(mr.cached_tokens)::bigint as cached_tokens,
sum(mr.cache_write_tokens)::bigint as cache_write_tokens,
sum(mr.reasoning_tokens)::bigint as reasoning_tokens,
sum(mr.image_input_tokens)::bigint as image_input_tokens,
sum(mr.image_output_tokens)::bigint as image_output_tokens,
count(mr.id) filter (where mr.image_generation_succeeded is true)::bigint
as image_request_count,
count(mr.id) filter (where mr.image_generation_succeeded is false)::bigint
as image_request_failed_count,
sum(mr.total_tokens)::bigint as total_tokens,
count(mr.id) filter (where mr.cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(mr.id) filter (where mr.cost_source = 'calculated')::bigint
as calculated_count,
count(mr.id) filter (where mr.cost_source = 'unavailable')::bigint
as unavailable_count,
max(mr.started_at) as last_used_at
from provider_accounts pa
left join model_requests mr
on mr.provider_account_ref = pa.id and mr.started_at >= ",
);
statement.push_bind(query.range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(query.range.end);
push_completed_usage_fact_filter(&mut statement, "mr");
if let Some(account_ids) = &query.account_ids {
statement.push(" where pa.id = any(");
statement.push_bind(account_ids.clone());
statement.push("::text[])");
}
statement.push(
" group by pa.id, pa.provider_kind, pa.authentication_kind, pa.name, pa.email,
pa.plan_type, pa.enabled, pa.availability
order by max(mr.started_at) desc nulls last, pa.name, pa.id limit ",
);
statement.push_bind(i64::from(query.limit));
let rows = statement
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load provider account usage"))?;
let mut observations = rows
.iter()
.map(provider_account_from_row)
.collect::<StoreResult<Vec<_>>>()?;
if observations.is_empty() {
return Ok(observations);
}
let account_ids = observations
.iter()
.map(|item| item.account_id.clone())
.collect::<Vec<_>>();
let mut request_buckets = if query.include_hourly_request_buckets {
provider_account_request_buckets(pool, query.range, &account_ids).await?
} else {
HashMap::new()
};
let account_costs = account_costs(pool, query.range, &account_ids).await?;
let model_rows = account_model_rows(pool, query.range, &account_ids).await?;
let model_costs = account_model_costs(pool, query.range, &account_ids).await?;
let account_positions = observations
.iter()
.enumerate()
.map(|(index, item)| (item.account_id.clone(), index))
.collect::<HashMap<_, _>>();
for observation in &mut observations {
observation.costs = account_costs
.get(&observation.account_id)
.cloned()
.unwrap_or_default();
observation.request_buckets = request_buckets
.remove(&observation.account_id)
.unwrap_or_default();
}
for row in model_rows {
let account_id: String = get(&row, "provider_account_ref")?;
let Some(position) = account_positions.get(&account_id).copied() else {
continue;
};
let model: String = get(&row, "model")?;
observations[position]
.models
.push(ProviderAccountModelUsageObservation {
costs: model_costs
.get(&(account_id, model.clone()))
.cloned()
.unwrap_or_default(),
model,
request_count: unsigned(&row, "request_count")?,
success_count: unsigned(&row, "success_count")?,
input_tokens: optional_unsigned(&row, "input_tokens")?,
output_tokens: optional_unsigned(&row, "output_tokens")?,
cached_tokens: optional_unsigned(&row, "cached_tokens")?,
cache_write_tokens: optional_unsigned(&row, "cache_write_tokens")?,
reasoning_tokens: optional_unsigned(&row, "reasoning_tokens")?,
image_input_tokens: optional_unsigned(&row, "image_input_tokens")?,
image_output_tokens: optional_unsigned(&row, "image_output_tokens")?,
image_request_count: unsigned(&row, "image_request_count")?,
image_request_failed_count: unsigned(&row, "image_request_failed_count")?,
total_tokens: optional_unsigned(&row, "total_tokens")?,
cost_coverage: coverage_from_row(&row)?,
last_used_at: get(&row, "last_used_at")?,
});
}
Ok(observations)
}
pub(crate) async fn provider_account_request_buckets(
pool: &PgPool,
range: ObservabilityRange,
account_ids: &[String],
) -> StoreResult<HashMap<String, Vec<ProviderAccountRequestBucket>>> {
let rows = sqlx::query(
"select provider_account_ref,
floor(extract(epoch from (started_at - $1)) / 3600)::bigint as bucket_index,
count(*)::bigint as request_count
from model_requests mr
where mr.provider_account_ref = any($2::text[])
and mr.started_at >= $1 and mr.started_at < $3
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
group by mr.provider_account_ref, bucket_index
order by mr.provider_account_ref, bucket_index",
)
.bind(range.start)
.bind(account_ids)
.bind(range.end)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load provider account request timeline"))?;
let mut observed = HashMap::<String, BTreeMap<u64, u64>>::new();
for row in rows {
observed
.entry(get(&row, "provider_account_ref")?)
.or_default()
.insert(
unsigned(&row, "bucket_index")?,
unsigned(&row, "request_count")?,
);
}
let step = TimeDelta::hours(1);
let mut timelines = HashMap::with_capacity(account_ids.len());
for account_id in account_ids {
let mut account_observed = observed.remove(account_id).unwrap_or_default();
let mut bucket_start = range.start;
let mut bucket_index = 0_u64;
let mut buckets = Vec::new();
while bucket_start < range.end {
buckets.push(ProviderAccountRequestBucket {
bucket_start,
request_count: account_observed.remove(&bucket_index).unwrap_or_default(),
});
bucket_start = bucket_start
.checked_add_signed(step)
.ok_or_else(|| invalid("account request timeline exceeds supported timestamps"))?;
bucket_index = bucket_index
.checked_add(1)
.ok_or_else(|| invalid("account request timeline exceeds supported buckets"))?;
}
timelines.insert(account_id.clone(), buckets);
}
Ok(timelines)
}
pub(crate) async fn account_costs(
pool: &PgPool,
range: ObservabilityRange,
account_ids: &[String],
) -> StoreResult<HashMap<String, Vec<CurrencyCostTotal>>> {
let rows = sqlx::query(
"select mr.provider_account_ref, mr.cost_currency, sum(mr.cost_amount)::text as amount
from model_requests mr
where mr.provider_account_ref = any($1::text[])
and mr.started_at >= $2 and mr.started_at < $3
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and mr.cost_amount is not null and mr.cost_currency is not null
group by mr.provider_account_ref, mr.cost_currency
order by mr.provider_account_ref, mr.cost_currency",
)
.bind(account_ids)
.bind(range.start)
.bind(range.end)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load provider account costs"))?;
let mut costs = HashMap::<String, Vec<CurrencyCostTotal>>::new();
for row in rows {
costs
.entry(get(&row, "provider_account_ref")?)
.or_default()
.push(cost_from_row(&row)?);
}
Ok(costs)
}
pub(crate) async fn account_model_rows(
pool: &PgPool,
range: ObservabilityRange,
account_ids: &[String],
) -> StoreResult<Vec<sqlx::postgres::PgRow>> {
sqlx::query(
"select mr.provider_account_ref,
coalesce(mr.upstream_model_id, mr.requested_model_id) as model,
count(*)::bigint as request_count,
count(*) filter (where mr.outcome = 'succeeded')::bigint as success_count,
sum(mr.input_tokens)::bigint as input_tokens,
sum(mr.output_tokens)::bigint as output_tokens,
sum(mr.cached_tokens)::bigint as cached_tokens,
sum(mr.cache_write_tokens)::bigint as cache_write_tokens,
sum(mr.reasoning_tokens)::bigint as reasoning_tokens,
sum(mr.image_input_tokens)::bigint as image_input_tokens,
sum(mr.image_output_tokens)::bigint as image_output_tokens,
count(*) filter (where mr.image_generation_succeeded is true)::bigint
as image_request_count,
count(*) filter (where mr.image_generation_succeeded is false)::bigint
as image_request_failed_count,
sum(mr.total_tokens)::bigint as total_tokens,
count(*) filter (where mr.cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(*) filter (where mr.cost_source = 'calculated')::bigint
as calculated_count,
count(*) filter (where mr.cost_source = 'unavailable')::bigint
as unavailable_count,
max(mr.started_at) as last_used_at
from model_requests mr
where mr.provider_account_ref = any($1::text[])
and mr.started_at >= $2 and mr.started_at < $3
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id)
order by mr.provider_account_ref, request_count desc, model",
)
.bind(account_ids)
.bind(range.start)
.bind(range.end)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load provider account model usage"))
}
pub(crate) async fn account_model_costs(
pool: &PgPool,
range: ObservabilityRange,
account_ids: &[String],
) -> StoreResult<HashMap<(String, String), Vec<CurrencyCostTotal>>> {
let rows = sqlx::query(
"select mr.provider_account_ref,
coalesce(mr.upstream_model_id, mr.requested_model_id) as model,
mr.cost_currency, sum(mr.cost_amount)::text as amount
from model_requests mr
where mr.provider_account_ref = any($1::text[])
and mr.started_at >= $2 and mr.started_at < $3
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and mr.cost_amount is not null and mr.cost_currency is not null
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id),
mr.cost_currency
order by mr.provider_account_ref, model, mr.cost_currency",
)
.bind(account_ids)
.bind(range.start)
.bind(range.end)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load provider account model costs"))?;
let mut costs = HashMap::<(String, String), Vec<CurrencyCostTotal>>::new();
for row in rows {
costs
.entry((get(&row, "provider_account_ref")?, get(&row, "model")?))
.or_default()
.push(cost_from_row(&row)?);
}
Ok(costs)
}
pub(crate) fn provider_account_from_row(
row: &sqlx::postgres::PgRow,
) -> StoreResult<ProviderAccountUsageObservation> {
Ok(ProviderAccountUsageObservation {
account_id: get(row, "id")?,
provider_kind: get(row, "provider_kind")?,
authentication_kind: get(row, "authentication_kind")?,
name: get(row, "name")?,
email: get(row, "email")?,
plan_type: get(row, "plan_type")?,
enabled: get(row, "enabled")?,
availability: get(row, "availability")?,
request_count: unsigned(row, "request_count")?,
success_count: unsigned(row, "success_count")?,
input_tokens: optional_unsigned(row, "input_tokens")?,
output_tokens: optional_unsigned(row, "output_tokens")?,
cached_tokens: optional_unsigned(row, "cached_tokens")?,
cache_write_tokens: optional_unsigned(row, "cache_write_tokens")?,
reasoning_tokens: optional_unsigned(row, "reasoning_tokens")?,
image_input_tokens: optional_unsigned(row, "image_input_tokens")?,
image_output_tokens: optional_unsigned(row, "image_output_tokens")?,
image_request_count: unsigned(row, "image_request_count")?,
image_request_failed_count: unsigned(row, "image_request_failed_count")?,
total_tokens: optional_unsigned(row, "total_tokens")?,
cost_coverage: coverage_from_row(row)?,
costs: Vec::new(),
last_used_at: get(row, "last_used_at")?,
request_buckets: Vec::new(),
models: Vec::new(),
})
}
@@ -0,0 +1,558 @@
//! Dashboard 与趋势查询族。
use super::super::*;
pub(crate) async fn request_metrics(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) -> StoreResult<RequestMetrics> {
filter.validate()?;
// 结果计数覆盖范围内全部请求(成功率/失败率的分母分子);用量、缓存、
// 延迟与成本聚合仅统计用量事实(已完整交付客户端的成功响应)。
let fact = completed_usage_fact_predicate("mr");
let mut query = QueryBuilder::<Postgres>::new(format!(
"select count(*)::bigint as request_count,
count(*) filter (where outcome = 'succeeded')::bigint as success_count,
count(*) filter (where outcome = 'failed')::bigint as failure_count,
count(*) filter (where outcome = 'cancelled')::bigint as cancelled_count,
count(*) filter (where outcome = 'incomplete')::bigint as incomplete_count,
count(*) filter (where client_status_code between 400 and 499)::bigint
as caller_error_count,
coalesce(sum(input_tokens) filter (where {fact}), 0)::bigint as input_tokens,
coalesce(sum(output_tokens) filter (where {fact}), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens) filter (where {fact}), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens) filter (where {fact}), 0)::bigint
as cache_write_tokens,
coalesce(sum(reasoning_tokens) filter (where {fact}), 0)::bigint
as reasoning_tokens,
coalesce(sum(total_tokens) filter (where {fact}), 0)::bigint as total_tokens,
coalesce(sum(first_token_ms) filter (where {fact}), 0)::bigint
as first_token_latency_sum,
count(first_token_ms) filter (where {fact})::bigint as first_token_latency_count,
coalesce(sum(latency_ms) filter (where {fact}), 0)::bigint as latency_sum,
count(latency_ms) filter (where {fact})::bigint as latency_count,
max(latency_ms) filter (where {fact})::bigint as max_latency_ms,
min(latency_ms) filter (where {fact})::bigint as min_latency_ms,
count(*) filter (where {fact} and input_tokens is not null)::bigint
as cache_eligible_request_count,
count(*) filter (where {fact} and input_tokens is not null
and cached_tokens > 0)::bigint
as cache_hit_request_count,
percentile_cont(0.50) within group (order by latency_ms)
filter (where {fact}) as latency_p50_ms,
percentile_cont(0.95) within group (order by latency_ms)
filter (where {fact}) as latency_p95_ms,
percentile_cont(0.99) within group (order by latency_ms)
filter (where {fact}) as latency_p99_ms,
percentile_cont(0.50) within group (order by first_token_ms)
filter (where {fact}) as first_token_p50_ms,
percentile_cont(0.95) within group (order by first_token_ms)
filter (where {fact}) as first_token_p95_ms,
percentile_cont(0.99) within group (order by first_token_ms)
filter (where {fact}) as first_token_p99_ms
from model_requests mr where mr.started_at >= "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_usage_filter(&mut query, filter, "mr");
let row = query
.build()
.fetch_one(pool)
.await
.map_err(|_| postgres_unavailable("load request metrics"))?;
request_metrics_from_row(&row)
}
pub(crate) async fn request_metric_series(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) -> StoreResult<Vec<RequestMetricPoint>> {
filter.validate()?;
let granularity = granularity_for(range);
// 与 request_metrics 同一契约:结果计数覆盖全部请求,用量/延迟/成本
// 聚合仅统计用量事实。
let fact = completed_usage_fact_predicate("mr");
let mut query = QueryBuilder::<Postgres>::new("select date_bin(");
query.push_bind(granularity.sql_interval());
query.push(format!(
"::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start,
count(*)::bigint as request_count,
count(*) filter (where outcome = 'succeeded')::bigint as success_count,
count(*) filter (where outcome = 'failed')::bigint as failure_count,
count(*) filter (where outcome = 'cancelled')::bigint as cancelled_count,
count(*) filter (where outcome = 'incomplete')::bigint as incomplete_count,
count(*) filter (where client_status_code between 400 and 499)::bigint
as caller_error_count,
coalesce(sum(input_tokens) filter (where {fact}), 0)::bigint as input_tokens,
coalesce(sum(output_tokens) filter (where {fact}), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens) filter (where {fact}), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens) filter (where {fact}), 0)::bigint
as cache_write_tokens,
coalesce(sum(reasoning_tokens) filter (where {fact}), 0)::bigint
as reasoning_tokens,
coalesce(sum(total_tokens) filter (where {fact}), 0)::bigint as total_tokens,
coalesce(sum(first_token_ms) filter (where {fact}), 0)::bigint
as first_token_latency_sum,
count(first_token_ms) filter (where {fact})::bigint as first_token_latency_count,
coalesce(sum(latency_ms) filter (where {fact}), 0)::bigint as latency_sum,
count(latency_ms) filter (where {fact})::bigint as latency_count,
max(latency_ms) filter (where {fact})::bigint as max_latency_ms,
min(latency_ms) filter (where {fact})::bigint as min_latency_ms,
count(*) filter (where {fact} and input_tokens is not null)::bigint
as cache_eligible_request_count,
count(*) filter (where {fact} and input_tokens is not null
and cached_tokens > 0)::bigint
as cache_hit_request_count,
percentile_cont(0.50) within group (order by latency_ms)
filter (where {fact}) as latency_p50_ms,
percentile_cont(0.95) within group (order by latency_ms)
filter (where {fact}) as latency_p95_ms,
percentile_cont(0.99) within group (order by latency_ms)
filter (where {fact}) as latency_p99_ms,
percentile_cont(0.50) within group (order by first_token_ms)
filter (where {fact}) as first_token_p50_ms,
percentile_cont(0.95) within group (order by first_token_ms)
filter (where {fact}) as first_token_p95_ms,
percentile_cont(0.99) within group (order by first_token_ms)
filter (where {fact}) as first_token_p99_ms,
count(*) filter (where {fact} and cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(*) filter (where {fact} and cost_source = 'calculated')::bigint
as calculated_count,
count(*) filter (where {fact} and cost_source = 'unavailable')::bigint
as unavailable_count
from model_requests mr where mr.started_at >= "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_usage_filter(&mut query, filter, "mr");
query.push(" group by bucket_start order by bucket_start");
let rows = query
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load request metric series"))?;
let mut points = BTreeMap::new();
for row in rows {
let bucket_start = get(&row, "bucket_start")?;
points.insert(
bucket_start,
RequestMetricPoint {
bucket_start,
granularity,
metrics: request_metrics_from_row(&row)?,
cost_coverage: coverage_from_row(&row)?,
costs: Vec::new(),
},
);
}
let bucket_costs = request_costs_by_bucket(pool, range, filter, granularity).await?;
for (bucket, costs) in bucket_costs {
if let Some(point) = points.get_mut(&bucket) {
point.costs = costs;
}
}
fill_metric_gaps(range, granularity, points)
}
pub(crate) async fn calculated_usage_billing_facts(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) -> StoreResult<Vec<CalculatedUsageBillingFact>> {
filter.validate()?;
let granularity = granularity_for(range);
let mut query = QueryBuilder::<Postgres>::new("select date_bin(");
query.push_bind(granularity.sql_interval());
query.push(
"::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start,
mr.provider_kind, mr.upstream_model_id, mr.service_tier,
mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens,
mr.cost_currency, mr.cost_amount::text as amount
from model_requests mr where mr.started_at >= ",
);
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
query.push(
" and mr.cost_source = 'calculated'
and mr.cost_amount is not null and mr.cost_currency is not null
and nullif(mr.provider_kind, '') is not null
and nullif(mr.upstream_model_id, '') is not null",
);
push_completed_usage_fact_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
query.push(" order by bucket_start, mr.id");
query
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load calculated usage billing facts"))?
.iter()
.map(calculated_usage_billing_fact_from_row)
.collect()
}
pub(crate) async fn request_costs_by_bucket(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
granularity: ObservationGranularity,
) -> StoreResult<BTreeMap<DateTime<Utc>, Vec<CurrencyCostTotal>>> {
let mut query = QueryBuilder::<Postgres>::new("select date_bin(");
query.push_bind(granularity.sql_interval());
query.push(
"::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start,
mr.cost_currency, sum(mr.cost_amount)::text as amount
from model_requests mr
where mr.started_at >= ",
);
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
query.push(" and mr.cost_amount is not null and mr.cost_currency is not null");
push_completed_usage_fact_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
query.push(" group by bucket_start, mr.cost_currency order by bucket_start, mr.cost_currency");
let rows = query
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load request costs by bucket"))?;
let mut result = BTreeMap::<DateTime<Utc>, Vec<CurrencyCostTotal>>::new();
for row in rows {
result
.entry(get(&row, "bucket_start")?)
.or_default()
.push(cost_from_row(&row)?);
}
Ok(result)
}
pub(crate) async fn attempt_metrics(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) -> StoreResult<AttemptMetrics> {
filter.validate()?;
let mut query = QueryBuilder::<Postgres>::new(
"with selected_requests as (
select mr.* from model_requests mr where mr.started_at >= ",
);
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_usage_filter(&mut query, filter, "mr");
query.push(
"), usage_facts as (
select * from selected_requests
where outcome = 'succeeded'
and downstream_committed_at is not null
and client_status_code between 200 and 399
), failures as (
select coalesce(oe.occurrence_count, 1)::bigint as occurrences,
oe.failure_kind, oe.status_code
from ops_events oe
join selected_requests sr on sr.id = oe.model_request_id
union all
select 1::bigint, coalesce(sr.error_kind, 'failed'),
coalesce(sr.upstream_status_code, sr.client_status_code)
from selected_requests sr
where sr.outcome = 'failed' and sr.attempt_count > 0
)
select coalesce((select sum(attempt_count) from selected_requests), 0)::bigint
as attempt_count,
coalesce((select count(*) from selected_requests
where outcome = 'succeeded' and attempt_count > 0), 0)::bigint
as success_count,
coalesce((select sum(occurrences) from failures), 0)::bigint as failure_count,
coalesce((select count(*) from selected_requests
where outcome = 'cancelled' and attempt_count > 0), 0)::bigint
as cancelled_count,
coalesce((select count(*) from selected_requests
where outcome = 'incomplete' and attempt_count > 0), 0)::bigint
as incomplete_count,
coalesce((select sum(occurrences) from failures
where failure_kind in ('rate_limited', 'quota_exhausted')
or status_code = 429), 0)::bigint as rate_limited_count,
coalesce((select sum(occurrences) from failures
where failure_kind in ('authentication', 'authorization',
'invalid_credential')
or status_code in (401, 403)), 0)::bigint as auth_failure_count,
coalesce((select sum(occurrences) from failures
where status_code between 500 and 599), 0)::bigint
as provider_5xx_count,
coalesce((select count(*) from usage_facts
where cost_source = 'provider_reported'), 0)::bigint
as provider_reported_count,
coalesce((select count(*) from usage_facts
where cost_source = 'calculated'), 0)::bigint
as calculated_count,
coalesce((select count(*) from usage_facts
where cost_source = 'unavailable'), 0)::bigint
as unavailable_count",
);
let row = query
.build()
.fetch_one(pool)
.await
.map_err(|_| postgres_unavailable("load attempt metrics"))?;
Ok(AttemptMetrics {
attempt_count: unsigned(&row, "attempt_count")?,
success_count: unsigned(&row, "success_count")?,
failure_count: unsigned(&row, "failure_count")?,
cancelled_count: unsigned(&row, "cancelled_count")?,
incomplete_count: unsigned(&row, "incomplete_count")?,
rate_limited_count: unsigned(&row, "rate_limited_count")?,
auth_failure_count: unsigned(&row, "auth_failure_count")?,
provider_5xx_count: unsigned(&row, "provider_5xx_count")?,
cost_coverage: coverage_from_row(&row)?,
costs: request_costs(pool, range, filter).await?,
})
}
pub(crate) async fn request_costs(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) -> StoreResult<Vec<CurrencyCostTotal>> {
let mut query = QueryBuilder::<Postgres>::new(
"select mr.cost_currency, sum(mr.cost_amount)::text as amount
from model_requests mr where mr.started_at >= ",
);
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
query.push(" and mr.cost_amount is not null and mr.cost_currency is not null");
push_completed_usage_fact_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
query.push(" group by mr.cost_currency order by mr.cost_currency");
query
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load request costs"))?
.iter()
.map(cost_from_row)
.collect()
}
pub(crate) async fn provider_account_metrics(
pool: &PgPool,
observed_at: DateTime<Utc>,
) -> StoreResult<ProviderAccountMetrics> {
let row = sqlx::query(
"with normalized as (
select enabled,
case
when not enabled then 'disabled'
when availability = 'banned' then 'banned'
when availability = 'quota_exhausted'
or (
availability = 'cooldown'
and availability_reason = 'usage_limit_exhausted'
and (cooldown_until is null or cooldown_until > $1)
)
then 'quota_exhausted'
when availability in ('expired', 'invalid')
or (access_token_expires_at is not null and access_token_expires_at <= $1)
then 'expired'
else 'active'
end as status
from provider_accounts
)
select count(*)::bigint as total,
count(*) filter (where enabled)::bigint as enabled,
count(*) filter (where status <> 'active')::bigint as unavailable,
count(*) filter (where status = 'active')::bigint as active,
count(*) filter (where status = 'expired')::bigint as expired,
count(*) filter (where status = 'quota_exhausted')::bigint as quota_exhausted,
count(*) filter (where status = 'disabled')::bigint as disabled,
count(*) filter (where status = 'banned')::bigint as banned
from normalized",
)
.bind(observed_at)
.fetch_one(pool)
.await
.map_err(|_| postgres_unavailable("load provider account metrics"))?;
Ok(ProviderAccountMetrics {
total: unsigned(&row, "total")?,
enabled: unsigned(&row, "enabled")?,
unavailable: unsigned(&row, "unavailable")?,
active: unsigned(&row, "active")?,
expired: unsigned(&row, "expired")?,
quota_exhausted: unsigned(&row, "quota_exhausted")?,
disabled: unsigned(&row, "disabled")?,
banned: unsigned(&row, "banned")?,
})
}
pub(crate) async fn active_provider_account_ids(
pool: &PgPool,
observed_at: DateTime<Utc>,
) -> StoreResult<Vec<String>> {
sqlx::query(
"with normalized as (
select id,
case
when not enabled then 'disabled'
when availability = 'banned' then 'banned'
when availability = 'quota_exhausted'
or (
availability = 'cooldown'
and availability_reason = 'usage_limit_exhausted'
and (cooldown_until is null or cooldown_until > $1)
)
then 'quota_exhausted'
when availability in ('expired', 'invalid')
or (access_token_expires_at is not null and access_token_expires_at <= $1)
then 'expired'
else 'active'
end as status
from provider_accounts
)
select id
from normalized
where status = 'active'
order by id",
)
.bind(observed_at)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load active provider account IDs"))?
.iter()
.map(|row| get(row, "id"))
.collect()
}
pub(crate) async fn provider_observations(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) -> StoreResult<Vec<ProviderObservation>> {
let mut query = QueryBuilder::<Postgres>::new(
"select coalesce(mr.provider_kind, 'unrouted') as provider_kind,
count(*)::bigint as request_count,
coalesce(sum(mr.attempt_count), 0)::bigint as attempt_count,
count(*) filter (where mr.outcome = 'failed')::bigint as failure_count,
coalesce(sum(mr.total_tokens), 0)::bigint as total_tokens
from model_requests mr where mr.started_at >= ",
);
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_completed_usage_fact_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
query.push(" group by coalesce(mr.provider_kind, 'unrouted') order by request_count desc");
let rows = query
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load provider observations"))?;
rows.iter()
.map(|row| {
Ok(ProviderObservation {
provider_kind: get(row, "provider_kind")?,
request_count: unsigned(row, "request_count")?,
attempt_count: unsigned(row, "attempt_count")?,
failure_count: unsigned(row, "failure_count")?,
total_tokens: unsigned(row, "total_tokens")?,
})
})
.collect()
}
pub(crate) fn request_metrics_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<RequestMetrics> {
Ok(RequestMetrics {
request_count: unsigned(row, "request_count")?,
success_count: unsigned(row, "success_count")?,
failure_count: unsigned(row, "failure_count")?,
cancelled_count: unsigned(row, "cancelled_count")?,
incomplete_count: unsigned(row, "incomplete_count")?,
caller_error_count: unsigned(row, "caller_error_count")?,
input_tokens: unsigned(row, "input_tokens")?,
output_tokens: unsigned(row, "output_tokens")?,
cached_tokens: unsigned(row, "cached_tokens")?,
cache_write_tokens: unsigned(row, "cache_write_tokens")?,
reasoning_tokens: unsigned(row, "reasoning_tokens")?,
total_tokens: unsigned(row, "total_tokens")?,
first_token_latency_sum: unsigned(row, "first_token_latency_sum")?,
first_token_latency_count: unsigned(row, "first_token_latency_count")?,
latency_sum: unsigned(row, "latency_sum")?,
latency_count: unsigned(row, "latency_count")?,
max_latency_ms: optional_unsigned(row, "max_latency_ms")?,
min_latency_ms: optional_unsigned(row, "min_latency_ms")?,
cache_eligible_request_count: unsigned(row, "cache_eligible_request_count")?,
cache_hit_request_count: unsigned(row, "cache_hit_request_count")?,
latency_percentiles: LatencyPercentiles {
p50_ms: optional_percentile(row, "latency_p50_ms")?,
p95_ms: optional_percentile(row, "latency_p95_ms")?,
p99_ms: optional_percentile(row, "latency_p99_ms")?,
},
first_token_latency_percentiles: LatencyPercentiles {
p50_ms: optional_percentile(row, "first_token_p50_ms")?,
p95_ms: optional_percentile(row, "first_token_p95_ms")?,
p99_ms: optional_percentile(row, "first_token_p99_ms")?,
},
})
}
pub(crate) fn optional_percentile(
row: &sqlx::postgres::PgRow,
column: &str,
) -> StoreResult<Option<PercentileMilliseconds>> {
row.try_get::<Option<f64>, _>(column)
.map_err(|_| postgres_unavailable("decode latency percentile"))?
.map(PercentileMilliseconds::new)
.transpose()
}
pub(crate) fn coverage_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<CostCoverage> {
Ok(CostCoverage {
provider_reported_count: unsigned(row, "provider_reported_count")?,
calculated_count: unsigned(row, "calculated_count")?,
unavailable_count: unsigned(row, "unavailable_count")?,
})
}
pub(crate) fn granularity_for(range: ObservabilityRange) -> ObservationGranularity {
let seconds = range.end.signed_duration_since(range.start).num_seconds();
if seconds <= 2 * 24 * 60 * 60 {
ObservationGranularity::FifteenMinutes
} else if seconds <= 31 * 24 * 60 * 60 {
ObservationGranularity::Hour
} else {
ObservationGranularity::Day
}
}
pub(crate) fn fill_metric_gaps(
range: ObservabilityRange,
granularity: ObservationGranularity,
mut points: BTreeMap<DateTime<Utc>, RequestMetricPoint>,
) -> StoreResult<Vec<RequestMetricPoint>> {
let seconds = granularity.seconds();
let start_epoch = range.start.timestamp().div_euclid(seconds) * seconds;
let mut bucket = DateTime::from_timestamp(start_epoch, 0)
.ok_or_else(|| invalid("metric range start is outside supported timestamps"))?;
let step = TimeDelta::seconds(seconds);
let mut result = Vec::new();
while bucket < range.end {
result.push(points.remove(&bucket).unwrap_or(RequestMetricPoint {
bucket_start: bucket,
granularity,
metrics: RequestMetrics::default(),
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
}));
bucket = bucket
.checked_add_signed(step)
.ok_or_else(|| invalid("metric range exceeds supported timestamps"))?;
}
Ok(result)
}
@@ -0,0 +1,11 @@
//! 观测查询族:dashboard / usage / ops / accounts。
mod accounts;
mod dashboard;
mod ops;
mod usage;
pub(crate) use accounts::*;
pub(crate) use dashboard::*;
pub(crate) use ops::*;
pub(crate) use usage::*;
@@ -0,0 +1,171 @@
//! Ops 错误查询族。
use super::super::*;
pub(crate) const OPS_ERRORS_CTE: &str = "with errors as (
select 'model_request'::text as source,
mr.id as event_id, mr.id as request_id,
nullif(mr.attempt_count, 0) as attempt_index,
mr.client_api_key_ref, 'model_request'::text as component, mr.operation,
mr.endpoint,
mr.provider_kind,
mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport,
coalesce(mr.error_kind, 'failed') as failure_kind,
mr.client_status_code, mr.upstream_status_code,
mr.provider_error_code, mr.client_response_id, mr.upstream_request_id,
mr.latency_ms, coalesce(mr.error_message, mr.error_kind, 'request failed') as message,
1::integer as occurrence_count,
coalesce(mr.completed_at, mr.started_at) as occurred_at,
'model_request:' || mr.id as stable_sort_id
from model_requests mr where mr.outcome = 'failed'
union all
select 'ops_event'::text, oe.id, oe.model_request_id, oe.attempt_index,
mr.client_api_key_ref, oe.component, oe.operation,
null::text as endpoint,
oe.provider_kind, oe.provider_account_ref,
oe.provider_account_name_snapshot as provider_account_name,
oe.provider_account_email_snapshot as provider_account_email,
oe.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
oe.upstream_model_id, null::text, oe.failure_kind,
null::integer as client_status_code, oe.status_code as upstream_status_code,
oe.provider_error_code, mr.client_response_id, oe.upstream_request_id,
oe.latency_ms, oe.message, oe.occurrence_count, oe.created_at,
'ops_event:' || oe.id
from ops_events oe left join model_requests mr on mr.id = oe.model_request_id
)";
pub(crate) async fn list_ops_errors(
pool: &PgPool,
query: OpsErrorQuery,
) -> StoreResult<OpsErrorPage> {
query.filter.validate()?;
let total = count_ops_errors(pool, query.range, &query.filter).await?;
let mut statement = QueryBuilder::<Postgres>::new(OPS_ERRORS_CTE);
statement.push(
" select source, event_id, request_id, attempt_index, client_api_key_ref,
component, operation, endpoint, provider_kind, provider_account_ref,
provider_account_name, provider_account_email,
provider_account_authentication_kind,
upstream_model_id, upstream_transport,
failure_kind, client_status_code, upstream_status_code,
provider_error_code, client_response_id,
upstream_request_id, latency_ms, message, occurrence_count, occurred_at,
stable_sort_id
from errors e where e.occurred_at >= ",
);
statement.push_bind(query.range.start);
statement.push(" and e.occurred_at < ");
statement.push_bind(query.range.end);
push_ops_filter(&mut statement, &query.filter);
if let Some(cursor) = &query.cursor {
statement.push(" and (e.occurred_at, e.stable_sort_id) < (");
statement.push_bind(cursor.observed_at);
statement.push(", ");
statement.push_bind(cursor.stable_id.clone());
statement.push(")");
}
statement.push(" order by e.occurred_at desc, e.stable_sort_id desc limit ");
statement.push_bind(i64::from(query.page_size.get()) + 1);
if query.cursor.is_none() {
let offset = u64::from(query.page.get() - 1)
.checked_mul(u64::from(query.page_size.get()))
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| invalid("ops error page offset is too large"))?;
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
.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,
total,
next_cursor,
})
}
pub(crate) async fn count_ops_errors(
pool: &PgPool,
range: ObservabilityRange,
filter: &OpsErrorFilter,
) -> StoreResult<u64> {
let mut statement = QueryBuilder::<Postgres>::new(OPS_ERRORS_CTE);
statement.push(" select count(*)::bigint from errors e where e.occurred_at >= ");
statement.push_bind(range.start);
statement.push(" and e.occurred_at < ");
statement.push_bind(range.end);
push_ops_filter(&mut statement, filter);
let total = statement
.build_query_scalar::<i64>()
.fetch_one(pool)
.await
.map_err(|_| postgres_unavailable("count ops errors"))?;
to_u64(total)
}
pub(crate) fn push_ops_filter(statement: &mut QueryBuilder<Postgres>, filter: &OpsErrorFilter) {
for (column, value) in [
("client_api_key_ref", &filter.client_api_key_ref),
("request_id", &filter.request_id),
("provider_account_ref", &filter.provider_account_ref),
("provider_kind", &filter.provider_kind),
("operation", &filter.operation),
("upstream_transport", &filter.transport),
("upstream_request_id", &filter.upstream_request_id),
("failure_kind", &filter.failure_kind),
] {
if let Some(value) = value {
statement.push(format!(" and e.{column} = "));
statement.push_bind(value.clone());
}
}
if let Some(value) = &filter.response_id {
statement.push(" and e.client_response_id = ");
statement.push_bind(value.as_bytes().to_vec());
}
if let Some(model) = &filter.model {
statement.push(" and e.upstream_model_id = ");
statement.push_bind(model.clone());
}
if let Some(index) = filter.attempt_index {
statement.push(" and e.attempt_index = ");
statement.push_bind(i32::try_from(index).unwrap_or(i32::MAX));
}
if let Some(status) = filter.status_code {
statement.push(" and e.upstream_status_code = ");
statement.push_bind(i32::from(status));
}
if let Some(search) = &filter.search {
statement.push(
" and lower(concat_ws(' ', e.event_id, e.request_id, e.client_api_key_ref,
e.component, e.operation, e.provider_kind, e.provider_account_ref,
e.upstream_model_id, e.failure_kind, e.provider_error_code,
e.upstream_request_id, e.message)) like ",
);
statement.push_bind(format!("%{}%", search.to_lowercase()));
}
}
@@ -0,0 +1,549 @@
//! Usage 明细、诊断与过滤查询族。
use super::super::*;
pub(crate) fn push_usage_filter(
query: &mut QueryBuilder<Postgres>,
filter: &UsageRecordFilter,
alias: &str,
) {
if let Some(value) = &filter.client_api_key_ref {
query.push(format!(" and {alias}.client_api_key_ref = "));
query.push_bind(value.clone());
}
if let Some(value) = &filter.request_id {
query.push(format!(" and {alias}.id = "));
query.push_bind(value.clone());
}
if let Some(value) = &filter.provider_account_ref {
query.push(format!(" and {alias}.provider_account_ref = "));
query.push_bind(value.clone());
}
if let Some(value) = &filter.operation {
query.push(format!(" and {alias}.operation = "));
query.push_bind(value.clone());
}
if let Some(value) = &filter.provider_kind {
query.push(format!(" and {alias}.provider_kind = "));
query.push_bind(value.clone());
}
if let Some(value) = &filter.model {
query.push(format!(" and ({alias}.requested_model_id = "));
query.push_bind(value.clone());
query.push(format!(" or {alias}.upstream_model_id = "));
query.push_bind(value.clone());
query.push(")");
}
if let Some(value) = &filter.outcome {
query.push(format!(" and {alias}.outcome = "));
query.push_bind(value.clone());
}
if let Some(value) = filter.status_code {
query.push(format!(" and ({alias}.client_status_code = "));
query.push_bind(i32::from(value));
query.push(format!(" or {alias}.upstream_status_code = "));
query.push_bind(i32::from(value));
query.push(")");
}
if let Some(value) = &filter.transport {
query.push(format!(" and ({alias}.client_transport = "));
query.push_bind(value.clone());
query.push(format!(" or {alias}.upstream_transport = "));
query.push_bind(value.clone());
query.push(")");
}
if let Some(value) = filter.attempt_index {
query.push(format!(" and ({alias}.attempt_count = "));
query.push_bind(i32::try_from(value).unwrap_or(i32::MAX));
query.push(format!(
" or exists (select 1 from ops_events attempt_event
where attempt_event.model_request_id = {alias}.id
and attempt_event.attempt_index = "
));
query.push_bind(i32::try_from(value).unwrap_or(i32::MAX));
query.push("))");
}
if let Some(value) = &filter.response_id {
query.push(format!(" and ({alias}.client_response_id = "));
query.push_bind(value.as_bytes().to_vec());
query.push(format!(" or {alias}.upstream_response_id = "));
query.push_bind(value.as_bytes().to_vec());
query.push(")");
}
if let Some(value) = &filter.upstream_request_id {
query.push(format!(" and {alias}.upstream_request_id = "));
query.push_bind(value.clone());
}
if let Some(value) = &filter.search {
query.push(format!(
" and lower(concat_ws(' ', {alias}.id, {alias}.client_api_key_ref,
{alias}.requested_model_id, {alias}.provider_kind,
{alias}.provider_account_ref, {alias}.upstream_model_id,
encode({alias}.client_response_id, 'escape'), {alias}.upstream_request_id,
{alias}.error_kind, {alias}.provider_error_code,
{alias}.error_message)) like "
));
query.push_bind(format!("%{}%", value.to_lowercase()));
}
}
/// 只把已完整交付给客户端的成功响应投影为使用事实。
///
/// `model_requests` 同时承担执行审计:包括上游已发送、但尚未收到首个事件就断开的
/// WebSocket 请求。这些失败仍必须留给 Ops Errors 和调度健康度分析,不能混入用量、
/// 成本、账号使用次数或请求明细。
pub(crate) fn completed_usage_fact_predicate(alias: &str) -> String {
format!(
"{alias}.outcome = 'succeeded' and {alias}.downstream_committed_at is not null and {alias}.client_status_code between 200 and 399"
)
}
pub(crate) fn push_completed_usage_fact_filter(query: &mut QueryBuilder<Postgres>, alias: &str) {
query.push(format!(" and {}", completed_usage_fact_predicate(alias)));
}
pub(crate) const USAGE_RECORD_SELECT: &str =
"select mr.id, mr.client_api_key_ref, mr.config_revision, mr.protocol, mr.operation,
mr.endpoint, mr.client_transport, mr.requested_model_id,
mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport, mr.http_version, mr.websocket_pool,
mr.service_tier, mr.provider_observation_json,
mr.attempt_count, mr.upstream_send_state, mr.downstream_committed_at,
mr.outcome, mr.client_status_code, mr.upstream_status_code,
mr.client_response_id, mr.upstream_request_id, mr.upstream_response_id,
mr.error_kind, mr.provider_error_code, mr.error_message, mr.retry_after_ms,
mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens,
mr.reasoning_tokens, mr.image_input_tokens, mr.image_output_tokens,
mr.total_tokens, mr.cost_source, mr.cost_amount::text,
mr.cost_currency, mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms,
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
mr.provider_processing_ms, mr.latency_ms, mr.client_ip::text as client_ip,
mr.user_agent, mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
mr.subagent_kind, mr.compact, mr.image_generation_requested,
mr.image_generation_succeeded, mr.started_at, mr.deadline_at, mr.completed_at
from model_requests mr";
pub(crate) async fn list_usage_records(
pool: &PgPool,
query: UsageRecordQuery,
) -> StoreResult<UsageRecordPage> {
query.filter.validate()?;
let total = count_usage_records(pool, query.range, &query.filter).await?;
let mut statement = QueryBuilder::<Postgres>::new(USAGE_RECORD_SELECT);
statement.push(" where mr.started_at >= ");
statement.push_bind(query.range.start);
statement.push(" and mr.started_at < ");
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);
if query.cursor.is_none() {
let offset = u64::from(query.page.get() - 1)
.checked_mul(u64::from(query.page_size.get()))
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| invalid("usage page offset is too large"))?;
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
.iter()
.map(usage_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,
})
}
pub(crate) async fn count_usage_records(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) -> StoreResult<u64> {
let mut statement = QueryBuilder::<Postgres>::new(
"select count(*)::bigint from model_requests mr where mr.started_at >= ",
);
statement.push_bind(range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(range.end);
push_completed_usage_fact_filter(&mut statement, "mr");
push_usage_filter(&mut statement, filter, "mr");
let total = statement
.build_query_scalar::<i64>()
.fetch_one(pool)
.await
.map_err(|_| postgres_unavailable("count usage records"))?;
to_u64(total)
}
pub(crate) async fn usage_record_detail(
pool: &PgPool,
request_id: &str,
) -> StoreResult<UsageRecordDetail> {
require_nonempty("model request", "id", request_id)?;
validate_text(request_id, MAX_FILTER_BYTES, "request ID")?;
let mut statement = QueryBuilder::<Postgres>::new(USAGE_RECORD_SELECT);
statement.push(" where mr.id = ");
statement.push_bind(request_id.to_owned());
push_completed_usage_fact_filter(&mut statement, "mr");
let row = statement
.build()
.fetch_optional(pool)
.await
.map_err(|_| postgres_unavailable("load usage record detail"))?
.ok_or_else(|| StoreError::NotFound {
entity: "model request",
id: request_id.to_owned(),
})?;
let request = usage_record_from_row(&row)?;
let rows = sqlx::query(
"select id, attempt_index, component, operation,
provider_kind, provider_account_ref,
provider_account_name_snapshot as provider_account_name,
provider_account_email_snapshot as provider_account_email,
provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
upstream_model_id,
failure_kind, status_code, provider_error_code, retry_after_ms,
upstream_request_id, latency_ms, message, created_at
from ops_events where model_request_id = $1
order by attempt_index, created_at, id",
)
.bind(request_id)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load usage attempt observations"))?;
let mut attempts = rows
.iter()
.map(intermediate_attempt_from_row)
.collect::<StoreResult<Vec<_>>>()?;
if request.attempt_count > 0 {
attempts.push(final_attempt_from_request(&request));
}
Ok(UsageRecordDetail { request, attempts })
}
pub(crate) fn intermediate_attempt_from_row(
row: &sqlx::postgres::PgRow,
) -> StoreResult<UsageAttemptObservation> {
Ok(UsageAttemptObservation {
source: "ops_event".to_owned(),
id: get(row, "id")?,
attempt_index: to_u32(get(row, "attempt_index")?)?,
component: get(row, "component")?,
operation: get(row, "operation")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: None,
upstream_send_state: None,
outcome: "failed".to_owned(),
downstream_committed: false,
status_code: optional_status(row, "status_code")?,
provider_error_code: get(row, "provider_error_code")?,
failure_kind: get(row, "failure_kind")?,
retry_after_ms: optional_unsigned(row, "retry_after_ms")?,
upstream_request_id: get(row, "upstream_request_id")?,
latency_ms: optional_unsigned(row, "latency_ms")?,
message: get(row, "message")?,
input_tokens: None,
output_tokens: None,
cached_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
total_tokens: None,
cost_source: None,
cost_amount: None,
cost_currency: None,
occurred_at: get(row, "created_at")?,
})
}
pub(crate) fn final_attempt_from_request(request: &UsageRecord) -> UsageAttemptObservation {
UsageAttemptObservation {
source: "model_request".to_owned(),
id: format!("{}:final", request.id),
attempt_index: request.attempt_count,
component: "model_request".to_owned(),
operation: request.operation.clone(),
provider_kind: request.provider_kind.clone(),
provider_account_ref: request.provider_account_ref.clone(),
provider_account_name: request.provider_account_name.clone(),
provider_account_email: request.provider_account_email.clone(),
provider_account_authentication_kind: request.provider_account_authentication_kind.clone(),
upstream_model_id: request.upstream_model_id.clone(),
upstream_transport: request.upstream_transport.clone(),
upstream_send_state: Some(request.upstream_send_state.clone()),
outcome: request.outcome.clone(),
downstream_committed: request.downstream_committed_at.is_some(),
status_code: request.upstream_status_code.or(request.client_status_code),
provider_error_code: request.provider_error_code.clone(),
failure_kind: request.error_kind.clone(),
retry_after_ms: request.retry_after_ms,
upstream_request_id: request.upstream_request_id.clone(),
latency_ms: request.latency_ms,
message: request.error_message.clone(),
input_tokens: request.input_tokens,
output_tokens: request.output_tokens,
cached_tokens: request.cached_tokens,
cache_write_tokens: request.cache_write_tokens,
reasoning_tokens: request.reasoning_tokens,
total_tokens: request.total_tokens,
cost_source: Some(request.cost_source.clone()),
cost_amount: request.cost_amount.clone(),
cost_currency: request.cost_currency.clone(),
occurred_at: request.completed_at.unwrap_or(request.started_at),
}
}
pub(crate) async fn usage_diagnostics(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>> {
filter.validate()?;
let dimension_sql = diagnostic_dimension_sql(dimension);
let mut statement = QueryBuilder::<Postgres>::new("select ");
statement.push(dimension_sql);
statement.push(
" as dimension_name,
count(*)::bigint as request_count,
count(*) filter (where mr.outcome = 'succeeded')::bigint as success_count,
count(*) filter (where mr.outcome = 'failed')::bigint as failure_count,
coalesce(sum(mr.attempt_count), 0)::bigint as attempt_count,
coalesce(sum(mr.total_tokens), 0)::bigint as total_tokens,
round(avg(mr.latency_ms))::bigint as average_latency_ms,
round(percentile_cont(0.95) within group (order by mr.latency_ms))::bigint
as latency_p95_ms,
count(*) filter (where mr.cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(*) filter (where mr.cost_source = 'calculated')::bigint
as calculated_count,
count(*) filter (where mr.cost_source = 'unavailable')::bigint
as unavailable_count
from model_requests mr where mr.started_at >= ",
);
statement.push_bind(range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(range.end);
push_diagnostic_fact_filter(&mut statement, dimension);
push_usage_filter(&mut statement, filter, "mr");
statement.push(" group by dimension_name order by request_count desc, dimension_name limit ");
statement.push_bind(DIAGNOSTIC_LIMIT);
let rows = statement
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load usage diagnostics"))?;
let mut observations = rows
.iter()
.map(|row| {
Ok(DiagnosticObservation {
key: get(row, "dimension_name")?,
name: get(row, "dimension_name")?,
request_count: unsigned(row, "request_count")?,
success_count: unsigned(row, "success_count")?,
failure_count: unsigned(row, "failure_count")?,
attempt_count: unsigned(row, "attempt_count")?,
total_tokens: unsigned(row, "total_tokens")?,
average_latency_ms: optional_unsigned(row, "average_latency_ms")?,
latency_p95_ms: optional_unsigned(row, "latency_p95_ms")?,
cost_coverage: coverage_from_row(row)?,
costs: Vec::new(),
})
})
.collect::<StoreResult<Vec<_>>>()?;
let mut display_names = match dimension {
DiagnosticDimension::Account => {
diagnostic_account_display_names(
pool,
&observations
.iter()
.map(|item| item.key.clone())
.collect::<Vec<_>>(),
)
.await?
}
DiagnosticDimension::ApiKey => {
diagnostic_api_key_display_names(
pool,
&observations
.iter()
.map(|item| item.key.clone())
.collect::<Vec<_>>(),
)
.await?
}
_ => HashMap::new(),
};
for observation in &mut observations {
if let Some(name) = display_names.remove(&observation.key) {
observation.name = name;
}
}
let positions = observations
.iter()
.enumerate()
.map(|(index, item)| (item.key.clone(), index))
.collect::<HashMap<_, _>>();
let costs = diagnostic_costs(pool, range, filter, dimension_sql, dimension).await?;
for (key, values) in costs {
if let Some(position) = positions.get(&key).copied() {
observations[position].costs = values;
}
}
Ok(observations)
}
pub(crate) async fn diagnostic_account_display_names(
pool: &PgPool,
account_ids: &[String],
) -> StoreResult<HashMap<String, String>> {
if account_ids.is_empty() {
return Ok(HashMap::new());
}
let rows = sqlx::query(
"select provider_account_ref,
coalesce(
(array_agg(provider_account_email_snapshot order by started_at desc)
filter (where provider_account_email_snapshot is not null))[1],
(array_agg(provider_account_name_snapshot order by started_at desc)
filter (where provider_account_name_snapshot is not null))[1],
provider_account_ref
) as display_name
from model_requests
where provider_account_ref = any($1::text[])
group by provider_account_ref",
)
.bind(account_ids)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load diagnostic account display names"))?;
let mut display_names = HashMap::with_capacity(rows.len());
for row in rows {
display_names.insert(
get(&row, "provider_account_ref")?,
get(&row, "display_name")?,
);
}
Ok(display_names)
}
pub(crate) async fn diagnostic_api_key_display_names(
pool: &PgPool,
key_ids: &[String],
) -> StoreResult<HashMap<String, String>> {
if key_ids.is_empty() {
return Ok(HashMap::new());
}
let rows = sqlx::query("select id, name from client_api_keys where id = any($1::text[])")
.bind(key_ids)
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load diagnostic api key display names"))?;
let mut display_names = HashMap::with_capacity(rows.len());
for row in rows {
display_names.insert(get(&row, "id")?, get(&row, "name")?);
}
Ok(display_names)
}
pub(crate) async fn diagnostic_costs(
pool: &PgPool,
range: ObservabilityRange,
filter: &UsageRecordFilter,
dimension_sql: &'static str,
dimension: DiagnosticDimension,
) -> StoreResult<HashMap<String, Vec<CurrencyCostTotal>>> {
let mut statement = QueryBuilder::<Postgres>::new("select ");
statement.push(dimension_sql);
statement.push(
" as dimension_name, mr.cost_currency, sum(mr.cost_amount)::text as amount
from model_requests mr where mr.started_at >= ",
);
statement.push_bind(range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(range.end);
statement.push(" and mr.cost_amount is not null and mr.cost_currency is not null");
push_diagnostic_fact_filter(&mut statement, dimension);
push_usage_filter(&mut statement, filter, "mr");
statement.push(
" group by dimension_name, mr.cost_currency order by dimension_name, mr.cost_currency",
);
let rows = statement
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load diagnostic costs"))?;
let mut result = HashMap::<String, Vec<CurrencyCostTotal>>::new();
for row in rows {
result
.entry(get(&row, "dimension_name")?)
.or_default()
.push(cost_from_row(&row)?);
}
Ok(result)
}
pub(crate) fn diagnostic_dimension_sql(dimension: DiagnosticDimension) -> &'static str {
match dimension {
DiagnosticDimension::Provider => "coalesce(mr.provider_kind, 'unrouted')",
DiagnosticDimension::Model => {
"coalesce(mr.upstream_model_id, mr.requested_model_id, 'unknown')"
}
DiagnosticDimension::Account => "coalesce(mr.provider_account_ref, 'unrouted')",
DiagnosticDimension::ApiKey => "mr.client_api_key_ref",
DiagnosticDimension::Transport => {
"coalesce(mr.upstream_transport, mr.client_transport, 'unknown')"
}
DiagnosticDimension::Failure => "coalesce(mr.error_kind, 'none')",
DiagnosticDimension::Status => {
"coalesce(mr.upstream_status_code, mr.client_status_code)::text"
}
}
}
pub(crate) fn push_diagnostic_fact_filter(
statement: &mut QueryBuilder<Postgres>,
dimension: DiagnosticDimension,
) {
if dimension == DiagnosticDimension::Failure {
statement.push(" and mr.error_kind is not null");
} else {
push_completed_usage_fact_filter(statement, "mr");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,663 @@
//! `gateway-admin` 账号端口的 PostgreSQL adapter。
use super::*;
/// Admin 账号用例所需的公共账号、留存观测与 revision 事务能力。
///
/// 三个 PostgreSQL adapter 都保持私有,调用方只能取得 [`AccountStore`] 暴露的领域能力。
#[derive(Clone)]
pub struct PgAdminAccountStore {
pool: PgPool,
accounts: PgProviderAccountRepository,
observability: PgObservabilityRepository,
control_plane: PgControlPlaneRepository,
}
impl PgAdminAccountStore {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self {
pool: pool.clone(),
accounts: PgProviderAccountRepository::new(pool.clone()),
observability: PgObservabilityRepository::new(pool.clone()),
control_plane: PgControlPlaneRepository::new(pool),
}
}
async fn usage_observations(
&self,
range: ObservabilityRange,
account_ids: &[String],
) -> AdminStoreResult<Vec<ProviderAccountUsageObservation>> {
if account_ids.is_empty() {
return Ok(Vec::new());
}
let mut observations = Vec::with_capacity(account_ids.len());
for account_ids in account_ids.chunks(ADMIN_USAGE_CHUNK_SIZE) {
let query = ProviderAccountUsageQuery::for_accounts(range, account_ids.to_vec())
.and_then(|query| {
if range.end.signed_duration_since(range.start) <= TimeDelta::hours(24) {
query.with_hourly_request_buckets()
} else {
Ok(query)
}
})
.map_err(|error| admin_store_error(ENTITY, error))?;
observations.extend(
self.observability
.provider_account_usage(query)
.await
.map_err(|error| admin_store_error(ENTITY, error))?,
);
}
Ok(observations)
}
async fn usage_by_windows(
&self,
windows: &[AccountUsageWindowQuery],
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
if windows.is_empty() {
return Ok(Vec::new());
}
let account_ids = windows
.iter()
.map(|window| window.account_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
validate_admin_account_ids(&account_ids)
.map_err(|error| admin_store_error(ENTITY, error))?;
for window in windows {
require_nonempty(ENTITY, "quota window key", &window.key)
.map_err(|error| admin_store_error(ENTITY, error))?;
ObservabilityRange::new(window.range.start, window.range.end)
.map_err(|error| admin_store_error(ENTITY, error))?;
}
let keys = windows
.iter()
.map(|window| window.key.clone())
.collect::<Vec<_>>();
let starts = windows
.iter()
.map(|window| window.range.start)
.collect::<Vec<_>>();
let ends = windows
.iter()
.map(|window| window.range.end)
.collect::<Vec<_>>();
let account_ids = windows
.iter()
.map(|window| window.account_id.clone())
.collect::<Vec<_>>();
sqlx::query(ACCOUNT_USAGE_BY_WINDOWS_SQL)
.bind(account_ids)
.bind(keys)
.bind(starts)
.bind(ends)
.fetch_all(&self.pool)
.await
.map_err(|_| {
admin_store_error(
ENTITY,
postgres_unavailable("load provider account quota window usage"),
)
})?
.iter()
.map(admin_account_usage_window)
.collect()
}
async fn required_scope(
&self,
account_id: &str,
) -> AdminStoreResult<ProviderAccountAdminScope> {
let record = self
.accounts
.load_provider_account(account_id)
.await
.map_err(|error| admin_store_error(ENTITY, error))?
.ok_or_else(|| {
admin_store_error(
ENTITY,
StoreError::NotFound {
entity: ENTITY,
id: account_id.to_owned(),
},
)
})?;
Ok(ProviderAccountAdminScope {
provider_kind: record.summary.provider_kind,
})
}
async fn commit_prepared_import(
&self,
prepared: PreparedCredentialImport,
context: &MutationContext,
action: &str,
) -> AdminStoreResult<CredentialImportResult> {
let provider_kind = prepared.provider_kind.as_str().to_owned();
let accounts = prepared
.credentials
.into_iter()
.map(prepared_account)
.collect::<StoreResult<Vec<_>>>()
.map_err(|error| admin_store_error(ENTITY, error))?;
let imported = self
.accounts
.import_provider_accounts(ImportProviderAccounts {
scope: ProviderAccountAdminScope {
provider_kind: provider_kind.clone(),
},
accounts,
audit: mutation_audit(
context,
action,
"provider_account",
&provider_kind,
vec!["credentials".to_owned()],
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
Ok(CredentialImportResult {
config_revision: admin_revision(imported.config_revision)?,
credential_ids: imported
.account_ids
.into_iter()
.map(CoreProviderAccountId::new)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
ENTITY,
"provider account import returned an invalid account ID",
)
})?,
})
}
async fn commit_prepared_rotation(
&self,
prepared: PreparedCredentialRotationFacts,
context: &MutationContext,
action: &str,
) -> AdminStoreResult<CredentialMutationResult> {
let account_id = prepared.account_id.clone();
let scope = ProviderAccountAdminScope {
provider_kind: prepared.provider_kind.as_str().to_owned(),
};
let rotation = self
.accounts
.rotate_provider_account(RotateProviderAccount {
scope,
profile: UpdateProviderAccount {
id: account_id.as_str().to_owned(),
name: prepared.name,
email: prepared.email,
plan_type: prepared.plan_type,
},
replacement_identity: prepared.replacement_identity,
credential: ProviderCredentialUpdate {
account_id: account_id.as_str().to_owned(),
expected_revision: store_revision(prepared.expected_credential_revision)?,
provider_credentials_json: provider_document_json(prepared.provider_material)
.map_err(|error| admin_store_error(ENTITY, error))?,
has_refresh_token: prepared.has_refresh_token,
access_token_expires_at: prepared.access_token_expires_at,
next_refresh_at: prepared.next_refresh_at,
},
audit: mutation_audit(
context,
action,
"provider_account",
account_id.as_str(),
vec!["credentials".to_owned()],
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
Ok(CredentialMutationResult {
config_revision: admin_revision(rotation.config_revision)?,
account_id,
credential_revision: Some(admin_revision(rotation.credential_revision)?),
})
}
}
#[async_trait]
impl AccountStore for PgAdminAccountStore {
async fn list_accounts(&self, query: AdminAccountListQuery) -> AdminStoreResult<AccountPage> {
if query.page == 0 {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"page number must be positive",
));
}
let (control_plane, accounts) = futures::try_join!(
self.control_plane.load_control_plane(),
self.accounts.list_provider_accounts(None, true),
)
.map_err(|error| admin_store_error(ENTITY, error))?;
let now = Utc::now();
let summary = admin_account_summary(&accounts, now);
let mut items = accounts
.into_iter()
.filter_map(|account| {
let status = admin_account_status(&account, now);
account_matches_admin_query(&account, status, &query).then_some(
AdminAccountListItem {
account,
status,
usage: None,
},
)
})
.collect::<Vec<_>>();
if query.sort.is_some_and(|sort| {
matches!(
sort.field,
AdminAccountSortField::Usage | AdminAccountSortField::LastUsedAt
)
}) {
let range = retained_usage_range(control_plane.settings.usage_retention_days, now)?;
let account_ids = items
.iter()
.map(|item| item.account.id.clone())
.collect::<Vec<_>>();
let mut usage_by_account = self
.usage_observations(range, &account_ids)
.await?
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<BTreeMap<_, _>>();
for item in &mut items {
item.usage = usage_by_account.remove(&item.account.id);
}
}
sort_admin_account_items(&mut items, query.sort);
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
let page_size = usize::from(query.page_size.get());
let offset = u64::from(query.page - 1).saturating_mul(u64::from(query.page_size.get()));
let offset = usize::try_from(offset).unwrap_or(usize::MAX);
let items = items
.into_iter()
.skip(offset)
.take(page_size)
.map(|item| admin_account_record(item.account))
.collect::<AdminStoreResult<Vec<_>>>()?;
Ok(AccountPage {
config_revision: admin_revision(control_plane.settings.config_revision)?,
items,
total,
summary,
})
}
async fn load_account(&self, account_id: &str) -> AdminStoreResult<Option<AccountRecord>> {
self.accounts
.load_provider_account(account_id)
.await
.map_err(|error| admin_store_error(ENTITY, error))?
.map(|record| admin_account_record(record.summary))
.transpose()
}
async fn load_account_usage(
&self,
range: TimeRange,
account_ids: &[String],
) -> AdminStoreResult<Vec<AccountUsage>> {
let range = ObservabilityRange::new(range.start, range.end)
.map_err(|error| admin_store_error(ENTITY, error))?;
self.usage_observations(range, account_ids)
.await?
.into_iter()
.map(admin_account_usage)
.collect()
}
async fn load_account_usage_by_windows(
&self,
windows: &[AccountUsageWindowQuery],
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
self.usage_by_windows(windows).await
}
async fn list_credentials(
&self,
provider_kind: &ProviderKind,
query: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
let (control_plane, accounts) = futures::try_join!(
self.control_plane.load_control_plane(),
self.accounts
.list_provider_accounts(Some(provider_kind.as_str()), true),
)
.map_err(|error| admin_store_error(ENTITY, error))?;
let mut accounts = accounts
.into_iter()
.filter(|account| account.provider_kind == provider_kind.as_str())
.filter(|account| {
query
.availability
.as_ref()
.is_none_or(|expected| expected.matches(account.availability))
})
.filter(|account| {
query
.enabled
.is_none_or(|enabled| account.enabled == enabled)
})
.filter(|account| {
let CredentialListWindow::Page {
cursor: Some(cursor),
..
} = &query.window
else {
return true;
};
account.created_at > cursor.created_at
|| (account.created_at == cursor.created_at
&& account.id.as_str() > cursor.account_id.as_str())
})
.collect::<Vec<_>>();
if matches!(&query.window, CredentialListWindow::Page { .. }) {
accounts.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.id.cmp(&right.id))
});
}
let next_cursor = match query.window {
CredentialListWindow::All => None,
CredentialListWindow::Page { page_size, .. } => {
let page_size = usize::from(page_size.get());
let has_more = accounts.len() > page_size;
accounts.truncate(page_size);
has_more
.then(|| accounts.last())
.flatten()
.map(|account| {
Ok(CredentialCursor {
created_at: account.created_at,
account_id: CoreProviderAccountId::new(account.id.clone()).map_err(
|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted Provider account ID is invalid",
)
},
)?,
})
})
.transpose()?
}
};
Ok(CredentialPage {
config_revision: admin_revision(control_plane.settings.config_revision)?,
items: accounts
.into_iter()
.map(admin_account_record)
.collect::<AdminStoreResult<Vec<_>>>()?,
next_cursor,
})
}
async fn credential_details(
&self,
provider_kind: &ProviderKind,
account_id: &CoreProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>> {
let (control_plane, account) = futures::try_join!(
self.control_plane.load_control_plane(),
self.accounts.load_provider_account(account_id.as_str()),
)
.map_err(|error| admin_store_error(ENTITY, error))?;
account
.filter(|record| record.summary.provider_kind == provider_kind.as_str())
.map(|record| {
Ok(CredentialDetails {
config_revision: admin_revision(control_plane.settings.config_revision)?,
credential: admin_account_record(record.summary)?,
})
})
.transpose()
}
async fn load_credentials_for_export(
&self,
provider_kind: &ProviderKind,
account_ids: &[CoreProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>> {
let ids = account_ids
.iter()
.map(|id| id.as_str().to_owned())
.collect::<Vec<_>>();
validate_admin_account_ids(&ids).map_err(|error| admin_store_error(ENTITY, error))?;
let mut credentials = Vec::with_capacity(account_ids.len());
for account_id in account_ids {
let record = self
.accounts
.load_provider_account(account_id.as_str())
.await
.map_err(|error| admin_store_error(ENTITY, error))?
.ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::NotFound,
ENTITY,
"one or more exported credentials do not exist",
)
})?;
if record.summary.provider_kind != provider_kind.as_str() {
return Err(AdminStoreError::new(
AdminStoreErrorKind::NotFound,
ENTITY,
"one or more exported credentials belong to another Provider",
));
}
credentials.push(ProviderExportCredentialInput {
account: admin_account_record(record.summary)?,
provider_material: ProviderDocument::new(OpaqueProviderData::new(
record.provider_credentials_json.fields().clone(),
)),
});
}
Ok(credentials)
}
async fn commit_credential_import(
&self,
command: CredentialImportCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
self.commit_prepared_import(command.prepared, context, "import_document")
.await
}
async fn commit_authorization(
&self,
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
match command.credential {
AuthorizationCredentialCommit::Create(credential) => {
let account_id = credential.account_id.clone();
let result = self
.commit_prepared_import(
PreparedCredentialImport {
provider_kind: credential.provider_kind.clone(),
credentials: vec![credential],
},
context,
"authorize",
)
.await?;
let details = self
.accounts
.load_provider_account(account_id.as_str())
.await
.map_err(|error| admin_store_error(ENTITY, error))?
.ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
ENTITY,
"authorized credential was not visible after commit",
)
})?;
Ok(CredentialMutationResult {
config_revision: result.config_revision,
account_id,
credential_revision: Some(admin_revision(details.summary.credential_revision)?),
})
}
AuthorizationCredentialCommit::Reauthorize(prepared) => {
self.commit_prepared_rotation(prepared, context, "reauthorize")
.await
}
}
}
async fn commit_credential_rotation(
&self,
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.commit_prepared_rotation(command.prepared, context, "rotate_credential")
.await
}
async fn commit_credential_refresh(
&self,
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.commit_prepared_rotation(command.prepared, context, "refresh_credential")
.await
}
async fn set_account_enabled(
&self,
command: SetAccountEnabled,
context: &MutationContext,
) -> AdminStoreResult<AdminRevision> {
let scope = self.required_scope(&command.account_id).await?;
let action = if command.enabled { "enable" } else { "disable" };
self.accounts
.set_provider_account_enabled_admin(SetProviderAccountEnabled {
scope,
account_id: command.account_id.clone(),
enabled: command.enabled,
audit: mutation_audit(
context,
action,
"provider_account",
&command.account_id,
vec!["enabled".to_owned()],
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))
.and_then(admin_revision)
}
async fn delete_accounts(
&self,
command: DeleteAccounts,
context: &MutationContext,
) -> AdminStoreResult<AdminRevision> {
let first_account_id = command.account_ids.first().ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"account deletion requires at least one account ID",
)
})?;
let scope = self.required_scope(first_account_id).await?;
let audit_target = if command.account_ids.len() == 1 {
first_account_id.clone()
} else {
"provider_accounts".to_owned()
};
self.accounts
.delete_provider_accounts_admin(DeleteProviderAccounts {
scope,
account_ids: command.account_ids,
audit: mutation_audit(
context,
"delete",
"provider_account",
&audit_target,
Vec::new(),
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))
.and_then(admin_revision)
}
async fn record_credential_export(
&self,
account_ids: &[CoreProviderAccountId],
context: &MutationContext,
) -> AdminStoreResult<()> {
let ids = account_ids
.iter()
.map(|id| id.as_str().to_owned())
.collect::<Vec<_>>();
validate_admin_account_ids(&ids).map_err(|error| admin_store_error(ENTITY, error))?;
for account_id in &ids {
if self
.accounts
.load_provider_account(account_id)
.await
.map_err(|error| admin_store_error(ENTITY, error))?
.is_none()
{
return Err(AdminStoreError::new(
AdminStoreErrorKind::NotFound,
ENTITY,
"one or more exported credentials do not exist",
));
}
}
let control_plane = self
.control_plane
.load_control_plane()
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
let revision = control_plane.settings.config_revision;
let mut transaction = self.accounts.pool.begin().await.map_err(|_| {
admin_store_error(
ENTITY,
postgres_unavailable("begin credential export audit"),
)
})?;
let result = async {
for account_id in &ids {
append_admin_audit_event_in_transaction(
&mut transaction,
mutation_audit(
context,
"export_credentials",
"provider_account",
account_id,
Vec::new(),
),
revision,
)
.await?;
}
Ok(())
}
.await;
finish_admin_transaction(transaction, result, "credential export audit")
.await
.map_err(|error| admin_store_error(ENTITY, error))
}
}
@@ -0,0 +1,293 @@
//! Core `ProviderAccountStore` 端口适配与 core 投影映射。
use super::*;
#[async_trait]
impl ProviderAccountStore for PgProviderAccountRepository {
async fn create_account(&self, account: CoreNewProviderAccount) -> Result<(), CoreStoreError> {
if account.account.revision().get() != 1 {
return Err(CoreStoreError::new(CoreStoreErrorKind::InvalidData));
}
let credential = JsonObject::try_from_value(
"provider_credentials_json",
serde_json::Value::Object(account.credential.into_inner()),
CREDENTIALS_MAX_BYTES,
)
.map_err(core_store_error)?;
self.insert_provider_account(NewProviderAccount {
id: account.account.id().as_str().to_owned(),
provider_kind: account.account.provider().as_str().to_owned(),
name: account.account.name().to_owned(),
email: account.account.email().map(str::to_owned),
upstream_user_id: account.account.upstream_user_id().to_owned(),
upstream_account_id: account.account.upstream_account_id().map(str::to_owned),
plan_type: account.account.plan_type().map(str::to_owned),
authentication_kind: account.account.authentication_kind().to_owned(),
provider_credentials_json: credential,
has_refresh_token: account.account.has_refresh_token(),
access_token_expires_at: account
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from),
next_refresh_at: account.account.next_refresh_at().map(DateTime::<Utc>::from),
enabled: account.account.enabled(),
availability: account.account.availability(),
availability_reason: None,
cooldown_until: account.account.cooldown_until().map(DateTime::<Utc>::from),
availability_observed_at: Utc::now(),
})
.await
.map_err(core_store_error)
}
async fn get_account(
&self,
account: &CoreProviderAccountId,
) -> Result<Option<CoreProviderAccount>, CoreStoreError> {
self.load_provider_account(account.as_str())
.await
.map_err(core_store_error)?
.map(|record| core_account_from_summary(record.summary))
.transpose()
}
async fn list_accounts(&self) -> Result<Vec<CoreProviderAccount>, CoreStoreError> {
self.list_provider_accounts(None, true)
.await
.map_err(core_store_error)?
.into_iter()
.map(core_account_from_summary)
.collect()
}
async fn list_for_provider(
&self,
provider: &ProviderKind,
) -> Result<Vec<CoreProviderAccount>, CoreStoreError> {
self.list_provider_accounts(Some(provider.as_str()), false)
.await
.map_err(core_store_error)?
.into_iter()
.map(core_account_from_summary)
.collect()
}
async fn load_credential(
&self,
account: &CoreProviderAccountId,
expected_revision: CoreCredentialRevision,
) -> Result<LoadedCredential, CoreStoreError> {
let loaded = self.load_current_credential(account).await?;
if loaded.account.revision().get() != expected_revision.get() {
return Err(CoreStoreError::new(CoreStoreErrorKind::Conflict));
}
Ok(loaded)
}
async fn load_current_credential(
&self,
account: &CoreProviderAccountId,
) -> Result<LoadedCredential, CoreStoreError> {
let record = self
.load_provider_account(account.as_str())
.await
.map_err(core_store_error)?
.ok_or_else(|| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
Ok(LoadedCredential {
account: core_account_from_summary(record.summary)?,
credential: PlaintextCredential::new(record.provider_credentials_json.fields().clone()),
})
}
async fn compare_and_swap_credential(
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, CoreStoreError> {
let (
account_id,
expected_revision,
profile,
credential,
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = update.into_parts();
if profile.account_id != account_id {
return Err(CoreStoreError::new(CoreStoreErrorKind::InvalidData));
}
let account_id = account_id.as_str().to_owned();
let credentials = JsonObject::try_from_value(
"provider_credentials_json",
serde_json::Value::Object(credential.into_inner()),
CREDENTIALS_MAX_BYTES,
)
.map_err(core_store_error)?;
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = $3, email = $4, plan_type = $5,
provider_credentials_json = $6,
credential_revision = credential_revision + 1,
has_refresh_token = $7, access_token_expires_at = $8,
next_refresh_at = $9, updated_at = now()
where id = $1 and credential_revision = $2
returning credential_revision",
)
.bind(&account_id)
.bind(to_i64(expected_revision.get()).map_err(core_store_error)?)
.bind(profile.name)
.bind(profile.email)
.bind(profile.plan_type)
.bind(credentials.as_value())
.bind(has_refresh_token)
.bind(access_token_expires_at.map(DateTime::<Utc>::from))
.bind(next_refresh_at.map(DateTime::<Utc>::from))
.fetch_optional(&self.pool)
.await
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::Unavailable))?;
match next {
Some(next) => Ok(CredentialCasOutcome::Updated(
CoreCredentialRevision::new(to_u64(next).map_err(core_store_error)?)
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?,
)),
None => Ok(CredentialCasOutcome::Conflict),
}
}
async fn get_quotas(
&self,
accounts: &[CoreProviderAccountId],
) -> Result<Vec<QuotaObservation>, CoreStoreError> {
if accounts.is_empty() {
return Ok(Vec::new());
}
let account_ids = accounts
.iter()
.map(|account| account.as_str().to_owned())
.collect::<Vec<_>>();
let rows = sqlx::query(
"select id, credential_revision, provider_quota_json, quota_observed_at \
from provider_accounts \
where id = any($1) and provider_quota_json is not null",
)
.bind(account_ids)
.fetch_all(&self.pool)
.await
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::Unavailable))?;
rows.into_iter()
.map(|row| {
let account_id = row
.try_get::<String, _>("id")
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let revision = row
.try_get::<i64, _>("credential_revision")
.ok()
.and_then(|value| u64::try_from(value).ok())
.and_then(|value| CoreCredentialRevision::new(value).ok())
.ok_or_else(|| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let quota = row
.try_get::<serde_json::Value, _>("provider_quota_json")
.ok()
.and_then(|value| value.as_object().cloned())
.map(OpaqueProviderData::new)
.ok_or_else(|| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let observed_at = row
.try_get::<DateTime<Utc>, _>("quota_observed_at")
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
Ok(QuotaObservation {
account_id: CoreProviderAccountId::new(account_id)
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?,
expected_revision: revision,
quota: Some(quota),
observed_at: Some(observed_at.into()),
})
})
.collect()
}
async fn compare_and_swap_quota(
&self,
observation: QuotaObservation,
) -> Result<QuotaWriteOutcome, CoreStoreError> {
let quota = observation
.quota
.map(|value| {
JsonObject::try_from_value(
"provider_quota_json",
serde_json::Value::Object(value.into_inner()),
QUOTA_MAX_BYTES,
)
})
.transpose()
.map_err(core_store_error)?;
let updated = self
.compare_and_swap_provider_quota(
observation.account_id.as_str(),
Revision::new(observation.expected_revision.get()).map_err(core_store_error)?,
quota,
observation.observed_at.map(DateTime::<Utc>::from),
)
.await
.map_err(core_store_error)?;
Ok(if updated {
QuotaWriteOutcome::Updated
} else {
QuotaWriteOutcome::Conflict
})
}
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), CoreStoreError> {
let updated = self
.apply_provider_account_state(ProviderAccountStateUpdate {
account_id: change.account_id.as_str().to_owned(),
expected_revision: Revision::new(change.expected_revision.get())
.map_err(core_store_error)?,
availability: change.availability,
availability_reason: change.reason,
cooldown_until: change.cooldown_until.map(DateTime::<Utc>::from),
availability_observed_at: DateTime::<Utc>::from(change.observed_at),
})
.await
.map_err(core_store_error)?;
if updated {
Ok(())
} else {
Err(CoreStoreError::new(CoreStoreErrorKind::Conflict))
}
}
async fn update_account(
&self,
update: CoreProviderAccountUpdate,
) -> Result<(), CoreStoreError> {
let updated = self
.update_provider_account(UpdateProviderAccount {
id: update.account_id.as_str().to_owned(),
name: update.name,
email: update.email,
plan_type: update.plan_type,
})
.await
.map_err(core_store_error)?;
require_core_update(updated)
}
async fn set_enabled(
&self,
account: &CoreProviderAccountId,
enabled: bool,
) -> Result<(), CoreStoreError> {
let updated = self
.set_provider_account_enabled(account.as_str(), enabled)
.await
.map_err(core_store_error)?;
require_core_update(updated)
}
async fn delete_account(&self, account: &CoreProviderAccountId) -> Result<(), CoreStoreError> {
let deleted = self
.delete_provider_account(account.as_str())
.await
.map_err(core_store_error)?;
require_core_update(deleted)
}
}
@@ -0,0 +1,392 @@
//! Admin 账号列表、用量与成本的领域映射。
use super::*;
pub(crate) struct AdminAccountListItem {
pub(crate) account: ProviderAccountSummary,
pub(crate) status: AdminAccountStatus,
pub(crate) usage: Option<ProviderAccountUsageObservation>,
}
pub(crate) fn retained_usage_range(
retention_days: u32,
now: DateTime<Utc>,
) -> AdminStoreResult<ObservabilityRange> {
let duration = TimeDelta::try_days(i64::from(retention_days)).ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"usage retention is outside the supported range",
)
})?;
let start = now.checked_sub_signed(duration).ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"usage retention start is outside the supported range",
)
})?;
ObservabilityRange::new(start, now).map_err(|error| admin_store_error(ENTITY, error))
}
pub(crate) fn account_matches_admin_query(
account: &ProviderAccountSummary,
status: AdminAccountStatus,
query: &AdminAccountListQuery,
) -> bool {
let provider_matches = query
.provider_kind
.as_ref()
.is_none_or(|provider| account.provider_kind == provider.as_str());
let search_matches = query.search.as_ref().is_none_or(|search| {
let search = search.to_lowercase();
[
Some(account.id.as_str()),
Some(account.name.as_str()),
account.email.as_deref(),
account.upstream_account_id.as_deref(),
Some(account.upstream_user_id.as_str()),
]
.into_iter()
.flatten()
.any(|value| value.to_lowercase().contains(&search))
});
let status_matches = query.status.is_none_or(|expected| expected == status);
provider_matches && search_matches && status_matches
}
pub(crate) fn admin_account_status(
account: &ProviderAccountSummary,
now: DateTime<Utc>,
) -> AdminAccountStatus {
if !account.enabled {
AdminAccountStatus::Disabled
} else if account.availability == AccountAvailability::Banned {
AdminAccountStatus::Banned
} else if account.availability == AccountAvailability::QuotaExhausted
|| (account.availability == AccountAvailability::Cooldown
&& account.availability_reason.as_deref() == Some("usage_limit_exhausted")
&& account.cooldown_until.is_none_or(|until| until > now))
{
AdminAccountStatus::QuotaExhausted
} else if account.availability == AccountAvailability::Expired
|| account.availability == AccountAvailability::Invalid
|| account
.access_token_expires_at
.is_some_and(|expires_at| expires_at <= now)
{
AdminAccountStatus::Expired
} else {
AdminAccountStatus::Active
}
}
pub(crate) fn admin_account_summary(
accounts: &[ProviderAccountSummary],
now: DateTime<Utc>,
) -> AccountSummary {
let mut summary = AccountSummary {
total: u64::try_from(accounts.len()).unwrap_or(u64::MAX),
active: 0,
quota_exhausted: 0,
unavailable: 0,
};
for account in accounts {
match admin_account_status(account, now) {
AdminAccountStatus::Active => summary.active = summary.active.saturating_add(1),
AdminAccountStatus::QuotaExhausted => {
summary.quota_exhausted = summary.quota_exhausted.saturating_add(1);
}
AdminAccountStatus::Expired
| AdminAccountStatus::Disabled
| AdminAccountStatus::Banned => {
summary.unavailable = summary.unavailable.saturating_add(1);
}
}
}
summary
}
pub(crate) fn sort_admin_account_items(
items: &mut [AdminAccountListItem],
sort: Option<AdminAccountSort>,
) {
let Some(sort) = sort else {
items.sort_by(|left, right| left.account.id.cmp(&right.account.id));
return;
};
items.sort_by(|left, right| {
let ordering = match sort.field {
AdminAccountSortField::Email => left.account.email.cmp(&right.account.email),
AdminAccountSortField::Status => {
admin_account_status_name(left.status).cmp(admin_account_status_name(right.status))
}
AdminAccountSortField::PlanType => left.account.plan_type.cmp(&right.account.plan_type),
AdminAccountSortField::Usage => left
.usage
.as_ref()
.and_then(|usage| usage.total_tokens)
.cmp(&right.usage.as_ref().and_then(|usage| usage.total_tokens)),
AdminAccountSortField::LastUsedAt => left
.usage
.as_ref()
.and_then(|usage| usage.last_used_at.as_ref())
.cmp(
&right
.usage
.as_ref()
.and_then(|usage| usage.last_used_at.as_ref()),
),
AdminAccountSortField::ExpiresAt => left
.account
.access_token_expires_at
.cmp(&right.account.access_token_expires_at),
}
.then_with(|| left.account.id.cmp(&right.account.id));
match sort.direction {
AdminSortDirection::Asc => ordering,
AdminSortDirection::Desc => ordering.reverse(),
}
});
}
pub(crate) const fn admin_account_status_name(status: AdminAccountStatus) -> &'static str {
match status {
AdminAccountStatus::Active => "active",
AdminAccountStatus::Expired => "expired",
AdminAccountStatus::QuotaExhausted => "quota_exhausted",
AdminAccountStatus::Disabled => "disabled",
AdminAccountStatus::Banned => "banned",
}
}
pub(crate) fn admin_account_record(
summary: ProviderAccountSummary,
) -> AdminStoreResult<AccountRecord> {
Ok(AccountRecord {
id: summary.id,
provider_kind: ProviderKind::new(summary.provider_kind).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted Provider kind is invalid",
)
})?,
name: summary.name,
email: summary.email,
upstream_user_id: summary.upstream_user_id,
upstream_account_id: summary.upstream_account_id,
plan_type: summary.plan_type,
authentication_kind: summary.authentication_kind,
credential_revision: admin_revision(summary.credential_revision)?,
has_refresh_token: summary.has_refresh_token,
access_token_expires_at: summary.access_token_expires_at,
next_refresh_at: summary.next_refresh_at,
enabled: summary.enabled,
availability: summary.availability,
availability_reason: summary.availability_reason,
cooldown_until: summary.cooldown_until,
availability_observed_at: summary.availability_observed_at,
quota_observed_at: summary.quota_observed_at,
created_at: summary.created_at,
updated_at: summary.updated_at,
})
}
pub(crate) fn prepared_account(
credential: PreparedCredentialCreate,
) -> StoreResult<NewProviderAccount> {
Ok(NewProviderAccount {
id: credential.account_id.as_str().to_owned(),
provider_kind: credential.provider_kind.as_str().to_owned(),
name: credential.name,
email: credential.email,
upstream_user_id: credential.upstream_user_id,
upstream_account_id: credential.upstream_account_id,
plan_type: credential.plan_type,
authentication_kind: credential.authentication_kind,
provider_credentials_json: provider_document_json(credential.provider_material)?,
has_refresh_token: credential.has_refresh_token,
access_token_expires_at: credential.access_token_expires_at,
next_refresh_at: credential.next_refresh_at,
enabled: credential.enabled,
availability: credential.availability,
availability_reason: credential.availability_reason,
cooldown_until: credential.cooldown_until,
availability_observed_at: credential.availability_observed_at,
})
}
pub(crate) fn provider_document_json(document: ProviderDocument) -> StoreResult<JsonObject> {
JsonObject::try_from_value(
ENTITY,
serde_json::Value::Object(document.into_provider_data().into_inner()),
CREDENTIALS_MAX_BYTES,
)
}
pub(crate) fn admin_account_usage(
usage: ProviderAccountUsageObservation,
) -> AdminStoreResult<AccountUsage> {
Ok(AccountUsage {
account_id: usage.account_id,
request_count: usage.request_count,
success_count: usage.success_count,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
cache_write_tokens: usage.cache_write_tokens,
reasoning_tokens: usage.reasoning_tokens,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
image_request_count: usage.image_request_count,
image_request_failed_count: usage.image_request_failed_count,
total_tokens: usage.total_tokens,
cost_coverage: admin_account_cost_coverage(usage.cost_coverage),
costs: admin_account_costs(usage.costs)?,
last_used_at: usage.last_used_at,
request_buckets: usage
.request_buckets
.into_iter()
.map(|bucket| AccountRequestBucket {
bucket_start: bucket.bucket_start,
request_count: bucket.request_count,
})
.collect(),
models: usage
.models
.into_iter()
.map(admin_account_model_usage)
.collect::<AdminStoreResult<Vec<_>>>()?,
})
}
pub(crate) fn admin_account_usage_window(
row: &sqlx::postgres::PgRow,
) -> AdminStoreResult<AccountUsageWindowResult> {
let provider_reported_count = window_usage_count(row, "provider_reported_count")?;
let calculated_count = window_usage_count(row, "calculated_count")?;
let unavailable_count = window_usage_count(row, "unavailable_count")?;
Ok(AccountUsageWindowResult {
account_id: window_usage_value(row, "account_id")?,
key: window_usage_value(row, "window_key")?,
usage: AccountUsage {
account_id: window_usage_value(row, "account_id")?,
request_count: window_usage_count(row, "request_count")?,
success_count: window_usage_count(row, "success_count")?,
input_tokens: optional_window_usage_count(row, "input_tokens")?,
output_tokens: optional_window_usage_count(row, "output_tokens")?,
cached_tokens: optional_window_usage_count(row, "cached_tokens")?,
cache_write_tokens: optional_window_usage_count(row, "cache_write_tokens")?,
reasoning_tokens: optional_window_usage_count(row, "reasoning_tokens")?,
image_input_tokens: optional_window_usage_count(row, "image_input_tokens")?,
image_output_tokens: optional_window_usage_count(row, "image_output_tokens")?,
image_request_count: window_usage_count(row, "image_request_count")?,
image_request_failed_count: window_usage_count(row, "image_request_failed_count")?,
total_tokens: optional_window_usage_count(row, "total_tokens")?,
cost_coverage: AdminCostCoverage {
provider_reported_count,
calculated_count,
partial_count: 0,
unavailable_count,
not_billable_count: 0,
},
costs: Vec::new(),
last_used_at: window_usage_value(row, "last_used_at")?,
request_buckets: Vec::new(),
models: Vec::new(),
},
})
}
pub(crate) fn window_usage_count(
row: &sqlx::postgres::PgRow,
column: &'static str,
) -> AdminStoreResult<u64> {
let value = window_usage_value::<i64>(row, column)?;
u64::try_from(value).map_err(|_| invalid_window_usage(column))
}
pub(crate) fn optional_window_usage_count(
row: &sqlx::postgres::PgRow,
column: &'static str,
) -> AdminStoreResult<Option<u64>> {
window_usage_value::<Option<i64>>(row, column)?
.map(|value| u64::try_from(value).map_err(|_| invalid_window_usage(column)))
.transpose()
}
pub(crate) fn window_usage_value<T>(
row: &sqlx::postgres::PgRow,
column: &'static str,
) -> AdminStoreResult<T>
where
for<'row> T: sqlx::Decode<'row, Postgres> + sqlx::Type<Postgres>,
{
row.try_get(column)
.map_err(|_| invalid_window_usage(column))
}
pub(crate) fn invalid_window_usage(column: &'static str) -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
format!("invalid quota window usage column: {column}"),
)
}
pub(crate) fn admin_account_model_usage(
usage: ProviderAccountModelUsageObservation,
) -> AdminStoreResult<AccountModelUsage> {
Ok(AccountModelUsage {
model: usage.model,
request_count: usage.request_count,
success_count: usage.success_count,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
cache_write_tokens: usage.cache_write_tokens,
reasoning_tokens: usage.reasoning_tokens,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
image_request_count: usage.image_request_count,
image_request_failed_count: usage.image_request_failed_count,
total_tokens: usage.total_tokens,
cost_coverage: admin_account_cost_coverage(usage.cost_coverage),
costs: admin_account_costs(usage.costs)?,
last_used_at: usage.last_used_at,
})
}
pub(crate) const fn admin_account_cost_coverage(
coverage: crate::postgres::CostCoverage,
) -> AdminCostCoverage {
AdminCostCoverage {
provider_reported_count: coverage.provider_reported_count,
calculated_count: coverage.calculated_count,
partial_count: 0,
unavailable_count: coverage.unavailable_count,
not_billable_count: 0,
}
}
pub(crate) fn admin_account_costs(
costs: Vec<CurrencyCostTotal>,
) -> AdminStoreResult<Vec<AccountCost>> {
costs
.into_iter()
.map(|cost| {
let amount = AdminDecimalAmount::from_str(cost.amount.as_str()).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted account cost is invalid",
)
})?;
Ok(AccountCost {
currency: cost.currency,
amount,
})
})
.collect()
}
@@ -0,0 +1,69 @@
//! 明文 `provider_accounts` 与凭证 revision CAS 的唯一 PostgreSQL owner。
use std::{
collections::{BTreeMap, BTreeSet},
fmt,
str::FromStr,
};
use async_trait::async_trait;
use chrono::{DateTime, TimeDelta, Utc};
use gateway_admin::{
model::{
MutationContext, Revision as AdminRevision,
accounts::{
AccountCost, AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
AccountRecord, AccountRequestBucket, AccountSort as AdminAccountSort,
AccountSortField as AdminAccountSortField, AccountStatus as AdminAccountStatus,
AccountSummary, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
DeleteAccounts, SetAccountEnabled, SortDirection as AdminSortDirection,
},
observability::{
CostCoverage as AdminCostCoverage, DecimalAmount as AdminDecimalAmount, TimeRange,
},
provider_credentials::{
AuthorizationCommit, AuthorizationCredentialCommit, CredentialCursor,
CredentialDetails, CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialListWindow, CredentialMutationResult, CredentialPage,
CredentialRotationCommit, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExportCredentialInput,
},
},
ports::store::{AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
};
use sqlx::{PgPool, Postgres, Row, Transaction};
use gateway_core::engine::credential::{
AccountAvailability, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
CredentialRevision as CoreCredentialRevision, LoadedCredential,
NewProviderAccount as CoreNewProviderAccount, OpaqueProviderData, PlaintextCredential,
ProviderAccount as CoreProviderAccount, ProviderAccountId as CoreProviderAccountId,
ProviderAccountIdentity, ProviderAccountStore,
ProviderAccountUpdate as CoreProviderAccountUpdate, QuotaObservation, QuotaWriteOutcome,
};
use gateway_core::error::{StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind};
use gateway_core::routing::ProviderKind;
use crate::{
ConflictKind, JsonObject, Revision, StoreError, StoreResult, admin_revision, admin_store_error,
mutation_audit, postgres_unavailable, require_nonempty, store_revision,
};
use super::{
AdminAuditEvent, ControlPlaneRepository, CurrencyCostTotal, ObservabilityRange,
ObservabilityRepository, PgControlPlaneRepository, PgObservabilityRepository,
ProviderAccountModelUsageObservation, ProviderAccountUsageObservation,
ProviderAccountUsageQuery, append_admin_audit_event_in_transaction,
bump_config_revision_in_transaction,
};
mod admin_adapter;
mod core_adapter;
mod mapping;
mod repository;
mod rows;
pub use admin_adapter::*;
pub(crate) use mapping::*;
pub use repository::*;
pub use rows::*;
@@ -0,0 +1,734 @@
//! Pg 账号 repository:Core/Admin 端口实现与 admin 事务。
use super::*;
#[async_trait]
pub trait ProviderAccountRepository: Send + Sync {
async fn load_provider_account(&self, id: &str) -> StoreResult<Option<ProviderAccountRecord>>;
async fn list_provider_accounts(
&self,
provider_kind: Option<&str>,
include_disabled: bool,
) -> StoreResult<Vec<ProviderAccountSummary>>;
async fn insert_provider_account(&self, account: NewProviderAccount) -> StoreResult<()>;
async fn update_provider_account(&self, account: UpdateProviderAccount) -> StoreResult<bool>;
async fn compare_and_swap_credentials(
&self,
update: ProviderCredentialUpdate,
) -> StoreResult<Revision>;
async fn apply_provider_account_state(
&self,
update: ProviderAccountStateUpdate,
) -> StoreResult<bool>;
async fn set_provider_account_enabled(&self, id: &str, enabled: bool) -> StoreResult<bool>;
async fn compare_and_swap_provider_quota(
&self,
account_id: &str,
expected_revision: Revision,
quota: Option<JsonObject>,
observed_at: Option<DateTime<Utc>>,
) -> StoreResult<bool>;
async fn delete_provider_account(&self, id: &str) -> StoreResult<bool>;
}
#[async_trait]
pub trait ProviderAccountAdminRepository: Send + Sync {
async fn export_provider_accounts(
&self,
scope: ProviderAccountAdminScope,
account_ids: Vec<String>,
) -> StoreResult<Vec<ProviderAccountRecord>>;
async fn import_provider_accounts(
&self,
command: ImportProviderAccounts,
) -> StoreResult<ProviderAccountAdminImport>;
async fn rotate_provider_account(
&self,
command: RotateProviderAccount,
) -> StoreResult<ProviderAccountAdminRotation>;
async fn set_provider_account_enabled_admin(
&self,
command: SetProviderAccountEnabled,
) -> StoreResult<Revision>;
async fn delete_provider_accounts_admin(
&self,
command: DeleteProviderAccounts,
) -> StoreResult<Revision>;
}
#[derive(Clone)]
pub struct PgProviderAccountRepository {
pub(crate) pool: PgPool,
}
impl PgProviderAccountRepository {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ProviderAccountRepository for PgProviderAccountRepository {
async fn load_provider_account(&self, id: &str) -> StoreResult<Option<ProviderAccountRecord>> {
require_nonempty(ENTITY, "id", id)?;
let row = sqlx::query(ACCOUNT_SELECT)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|_| postgres_unavailable("load provider account"))?;
row.map(account_record_from_row).transpose()
}
async fn list_provider_accounts(
&self,
provider_kind: Option<&str>,
include_disabled: bool,
) -> StoreResult<Vec<ProviderAccountSummary>> {
let rows = sqlx::query(
"select id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token,
access_token_expires_at, next_refresh_at, enabled, availability,
availability_reason, cooldown_until, availability_observed_at,
quota_observed_at, created_at, updated_at
from provider_accounts
where ($1::text is null or provider_kind = $1) and ($2 or enabled)
order by provider_kind, name, id",
)
.bind(provider_kind)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_err(|_| postgres_unavailable("list provider accounts"))?;
rows.into_iter().map(account_summary_from_row).collect()
}
async fn insert_provider_account(&self, account: NewProviderAccount) -> StoreResult<()> {
account.validate()?;
sqlx::query(
"insert into provider_accounts (
id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
availability, availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$14, null, $15, null, $16, null, now(), greatest(now(), $16)
)",
)
.bind(account.id)
.bind(account.provider_kind)
.bind(account.name)
.bind(account.email)
.bind(account.upstream_user_id)
.bind(account.upstream_account_id)
.bind(account.plan_type)
.bind(account.authentication_kind)
.bind(account.provider_credentials_json.as_value())
.bind(account.has_refresh_token)
.bind(account.access_token_expires_at)
.bind(account.next_refresh_at)
.bind(account.enabled)
.bind(account.availability.as_str())
.bind(account.cooldown_until)
.bind(account.availability_observed_at)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("insert provider account"))?;
Ok(())
}
async fn update_provider_account(&self, account: UpdateProviderAccount) -> StoreResult<bool> {
require_nonempty(ENTITY, "id", &account.id)?;
require_nonempty(ENTITY, "name", &account.name)?;
let result = sqlx::query(
"update provider_accounts
set name = $2, email = $3, plan_type = $4, updated_at = now()
where id = $1",
)
.bind(account.id)
.bind(account.name)
.bind(account.email)
.bind(account.plan_type)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("update provider account"))?;
Ok(result.rows_affected() == 1)
}
async fn compare_and_swap_credentials(
&self,
update: ProviderCredentialUpdate,
) -> StoreResult<Revision> {
require_nonempty(ENTITY, "account_id", &update.account_id)?;
validate_object_size(
"provider_credentials_json",
&update.provider_credentials_json,
CREDENTIALS_MAX_BYTES,
)?;
if !update.has_refresh_token && update.next_refresh_at.is_some() {
return Err(invalid("next_refresh_at requires a refresh token"));
}
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set provider_credentials_json = $3,
credential_revision = credential_revision + 1,
has_refresh_token = $4,
access_token_expires_at = $5,
next_refresh_at = $6,
updated_at = now()
where id = $1 and credential_revision = $2
returning credential_revision",
)
.bind(&update.account_id)
.bind(to_i64(update.expected_revision.get())?)
.bind(update.provider_credentials_json.as_value())
.bind(update.has_refresh_token)
.bind(update.access_token_expires_at)
.bind(update.next_refresh_at)
.fetch_optional(&self.pool)
.await
.map_err(|_| postgres_unavailable("compare and swap provider credentials"))?
.ok_or(StoreError::Conflict {
entity: ENTITY,
id: update.account_id,
kind: ConflictKind::StaleRevision,
})?;
Revision::new(to_u64(next)?)
}
async fn apply_provider_account_state(
&self,
update: ProviderAccountStateUpdate,
) -> StoreResult<bool> {
update.validate()?;
let result = sqlx::query(
"update provider_accounts
set availability = case when enabled then $3 else availability end,
availability_reason = case when enabled then $4 else availability_reason end,
cooldown_until = case when enabled then $5 else cooldown_until end,
availability_observed_at = case
when enabled then $6
else availability_observed_at
end,
updated_at = case when enabled then greatest(now(), $6) else updated_at end
where id = $1 and credential_revision = $2
and (availability_observed_at is null or availability_observed_at <= $6)",
)
.bind(update.account_id)
.bind(to_i64(update.expected_revision.get())?)
.bind(update.availability.as_str())
.bind(update.availability_reason)
.bind(update.cooldown_until)
.bind(update.availability_observed_at)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("apply provider account state"))?;
Ok(result.rows_affected() == 1)
}
async fn set_provider_account_enabled(&self, id: &str, enabled: bool) -> StoreResult<bool> {
require_nonempty(ENTITY, "id", id)?;
let result = sqlx::query(
"update provider_accounts set enabled = $2, updated_at = now() where id = $1",
)
.bind(id)
.bind(enabled)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("set provider account enabled state"))?;
Ok(result.rows_affected() == 1)
}
async fn compare_and_swap_provider_quota(
&self,
account_id: &str,
expected_revision: Revision,
quota: Option<JsonObject>,
observed_at: Option<DateTime<Utc>>,
) -> StoreResult<bool> {
require_nonempty(ENTITY, "account_id", account_id)?;
if quota.is_some() != observed_at.is_some() {
return Err(invalid("quota JSON and observed_at must agree"));
}
if let Some(quota) = quota.as_ref() {
validate_object_size("provider_quota_json", quota, QUOTA_MAX_BYTES)?;
}
let result = sqlx::query(
"update provider_accounts
set provider_quota_json = $3, quota_observed_at = $4,
updated_at = greatest(now(), coalesce($4, now()))
where id = $1 and credential_revision = $2
and ($4 is null or quota_observed_at is null or quota_observed_at <= $4)",
)
.bind(account_id)
.bind(to_i64(expected_revision.get())?)
.bind(quota.map(|value| value.as_value()))
.bind(observed_at)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("compare and swap provider quota"))?;
Ok(result.rows_affected() == 1)
}
async fn delete_provider_account(&self, id: &str) -> StoreResult<bool> {
require_nonempty(ENTITY, "id", id)?;
let result = sqlx::query("delete from provider_accounts where id = $1 and not enabled")
.bind(id)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("delete disabled provider account"))?;
Ok(result.rows_affected() == 1)
}
}
#[async_trait]
impl ProviderAccountAdminRepository for PgProviderAccountRepository {
async fn export_provider_accounts(
&self,
scope: ProviderAccountAdminScope,
account_ids: Vec<String>,
) -> StoreResult<Vec<ProviderAccountRecord>> {
scope.validate()?;
validate_admin_account_ids(&account_ids)?;
let rows = sqlx::query(ACCOUNT_SELECT_BY_IDS)
.bind(&account_ids)
.bind(&scope.provider_kind)
.fetch_all(&self.pool)
.await
.map_err(|_| postgres_unavailable("export provider accounts"))?;
let records = rows
.into_iter()
.map(account_record_from_row)
.collect::<StoreResult<Vec<_>>>()?;
if records.len() != account_ids.len() {
return Err(invalid(
"one or more exported accounts are missing or outside the Provider scope",
));
}
let by_id = records
.into_iter()
.map(|record| (record.summary.id.clone(), record))
.collect::<std::collections::HashMap<_, _>>();
account_ids
.into_iter()
.map(|id| {
by_id.get(&id).cloned().ok_or_else(|| {
invalid("one or more exported accounts are missing after loading")
})
})
.collect()
}
async fn import_provider_accounts(
&self,
command: ImportProviderAccounts,
) -> StoreResult<ProviderAccountAdminImport> {
command.validate()?;
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| postgres_unavailable("begin provider account admin import"))?;
let result = async {
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
let mut account_ids = Vec::with_capacity(command.accounts.len());
for account in &command.accounts {
account_ids
.push(upsert_provider_account_in_transaction(&mut transaction, account).await?);
}
append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision)
.await?;
Ok(ProviderAccountAdminImport {
config_revision: revision,
account_ids,
})
}
.await;
finish_admin_transaction(transaction, result, "provider account admin import").await
}
async fn rotate_provider_account(
&self,
command: RotateProviderAccount,
) -> StoreResult<ProviderAccountAdminRotation> {
command.scope.validate()?;
require_nonempty(ENTITY, "account_id", &command.profile.id)?;
require_nonempty(ENTITY, "name", &command.profile.name)?;
if let Some(identity) = &command.replacement_identity {
require_nonempty(ENTITY, "upstream_user_id", identity.upstream_user_id())?;
if let Some(account_id) = identity.upstream_account_id() {
require_nonempty(ENTITY, "upstream_account_id", account_id)?;
}
}
if command.profile.id != command.credential.account_id {
return Err(invalid("rotated profile and credential account IDs differ"));
}
validate_credential_update(&command.credential)?;
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| postgres_unavailable("begin provider account admin rotation"))?;
let result = async {
let config_revision = bump_config_revision_in_transaction(&mut transaction).await?;
let credential_revision = rotate_provider_account_in_transaction(
&mut transaction,
&command.scope,
&command.profile,
command.replacement_identity.as_ref(),
&command.credential,
)
.await?;
append_admin_audit_event_in_transaction(
&mut transaction,
command.audit,
config_revision,
)
.await?;
Ok(ProviderAccountAdminRotation {
config_revision,
credential_revision,
})
}
.await;
finish_admin_transaction(transaction, result, "provider account admin rotation").await
}
async fn set_provider_account_enabled_admin(
&self,
command: SetProviderAccountEnabled,
) -> StoreResult<Revision> {
command.scope.validate()?;
require_nonempty(ENTITY, "account_id", &command.account_id)?;
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| postgres_unavailable("begin provider account admin state change"))?;
let result = async {
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
set_provider_account_enabled_in_transaction(
&mut transaction,
&command.scope,
&command.account_id,
command.enabled,
)
.await?;
append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision)
.await?;
Ok(revision)
}
.await;
finish_admin_transaction(transaction, result, "provider account admin state change").await
}
async fn delete_provider_accounts_admin(
&self,
command: DeleteProviderAccounts,
) -> StoreResult<Revision> {
command.scope.validate()?;
validate_admin_account_ids(&command.account_ids)?;
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| postgres_unavailable("begin provider account admin deletion"))?;
let result = async {
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
delete_provider_accounts_in_transaction(
&mut transaction,
&command.scope,
&command.account_ids,
)
.await?;
append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision)
.await?;
Ok(revision)
}
.await;
finish_admin_transaction(transaction, result, "provider account admin deletion").await
}
}
pub(crate) async fn upsert_provider_account_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
account: &NewProviderAccount,
) -> StoreResult<String> {
account.validate()?;
let imported_id = sqlx::query_scalar::<_, String>(
"insert into provider_accounts (
id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
availability, availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$14, $15, $16, null, $17, null, now(), greatest(now(), $17)
)
on conflict (
provider_kind,
upstream_user_id,
(coalesce(upstream_account_id, ''))
) do update set
name = excluded.name,
email = excluded.email,
plan_type = excluded.plan_type,
authentication_kind = excluded.authentication_kind,
provider_credentials_json = excluded.provider_credentials_json,
credential_revision = provider_accounts.credential_revision + 1,
has_refresh_token = excluded.has_refresh_token,
access_token_expires_at = excluded.access_token_expires_at,
next_refresh_at = excluded.next_refresh_at,
enabled = excluded.enabled,
availability = excluded.availability,
availability_reason = excluded.availability_reason,
cooldown_until = excluded.cooldown_until,
provider_quota_json = null,
availability_observed_at = excluded.availability_observed_at,
quota_observed_at = null,
updated_at = greatest(now(), excluded.availability_observed_at)
returning id",
)
.bind(&account.id)
.bind(&account.provider_kind)
.bind(&account.name)
.bind(&account.email)
.bind(&account.upstream_user_id)
.bind(&account.upstream_account_id)
.bind(&account.plan_type)
.bind(&account.authentication_kind)
.bind(account.provider_credentials_json.as_value())
.bind(account.has_refresh_token)
.bind(account.access_token_expires_at)
.bind(account.next_refresh_at)
.bind(account.enabled)
.bind(account.availability.as_str())
.bind(&account.availability_reason)
.bind(account.cooldown_until)
.bind(account.availability_observed_at)
.fetch_optional(&mut **transaction)
.await
.map_err(|error| {
if error
.as_database_error()
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
{
StoreError::Conflict {
entity: ENTITY,
id: account.id.clone(),
kind: ConflictKind::InvalidTransition,
}
} else {
postgres_unavailable("upsert provider account in admin transaction")
}
})?
.ok_or_else(|| StoreError::Conflict {
entity: ENTITY,
id: account.id.clone(),
kind: ConflictKind::InvalidTransition,
})?;
Ok(imported_id)
}
pub(crate) async fn rotate_provider_account_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
scope: &ProviderAccountAdminScope,
profile: &UpdateProviderAccount,
replacement_identity: Option<&ProviderAccountIdentity>,
update: &ProviderCredentialUpdate,
) -> StoreResult<Revision> {
let replace_identity = replacement_identity.is_some();
let upstream_user_id = replacement_identity.map(ProviderAccountIdentity::upstream_user_id);
let upstream_account_id =
replacement_identity.and_then(ProviderAccountIdentity::upstream_account_id);
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = $4,
email = $5,
plan_type = $6,
provider_credentials_json = $7,
credential_revision = credential_revision + 1,
has_refresh_token = $8,
access_token_expires_at = $9,
next_refresh_at = $10,
upstream_user_id = case when $11::boolean then $12::text else upstream_user_id end,
upstream_account_id = case when $11::boolean then $13::text else upstream_account_id end,
availability = case
when not enabled then availability
when availability <> 'quota_exhausted' then 'ready'
else availability
end,
availability_reason = case
when not enabled then availability_reason
when availability <> 'quota_exhausted' then null
else availability_reason
end,
cooldown_until = case
when not enabled then cooldown_until
when availability <> 'quota_exhausted' then null
else cooldown_until
end,
availability_observed_at = case
when not enabled then availability_observed_at
when availability <> 'quota_exhausted' then now()
else availability_observed_at
end,
updated_at = now()
where id = $1 and provider_kind = $2
and credential_revision = $3
returning credential_revision",
)
.bind(&update.account_id)
.bind(&scope.provider_kind)
.bind(to_i64(update.expected_revision.get())?)
.bind(&profile.name)
.bind(&profile.email)
.bind(&profile.plan_type)
.bind(update.provider_credentials_json.as_value())
.bind(update.has_refresh_token)
.bind(update.access_token_expires_at)
.bind(update.next_refresh_at)
.bind(replace_identity)
.bind(upstream_user_id)
.bind(upstream_account_id)
.fetch_optional(&mut **transaction)
.await
.map_err(|error| {
if error
.as_database_error()
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
{
StoreError::Conflict {
entity: ENTITY,
id: update.account_id.clone(),
kind: ConflictKind::InvalidTransition,
}
} else {
postgres_unavailable("rotate provider account in admin transaction")
}
})?
.ok_or_else(|| StoreError::Conflict {
entity: ENTITY,
id: update.account_id.clone(),
kind: ConflictKind::StaleRevision,
})?;
Revision::new(to_u64(next)?)
}
pub(crate) async fn set_provider_account_enabled_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
scope: &ProviderAccountAdminScope,
account_id: &str,
enabled: bool,
) -> StoreResult<()> {
let result = sqlx::query(
"update provider_accounts set enabled = $3, updated_at = now()
where id = $1 and provider_kind = $2",
)
.bind(account_id)
.bind(&scope.provider_kind)
.bind(enabled)
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("set provider account state in admin transaction"))?;
require_admin_account_changed(result.rows_affected(), account_id)
}
pub(crate) async fn delete_provider_accounts_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
scope: &ProviderAccountAdminScope,
account_ids: &[String],
) -> StoreResult<()> {
let deleted = sqlx::query_scalar::<_, String>(
"delete from provider_accounts
where id = any($1::text[]) and provider_kind = $2
returning id",
)
.bind(account_ids)
.bind(&scope.provider_kind)
.fetch_all(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("delete provider account in admin transaction"))?;
let deleted = deleted.into_iter().collect::<BTreeSet<_>>();
let expected = account_ids.iter().cloned().collect::<BTreeSet<_>>();
if deleted == expected {
Ok(())
} else {
Err(invalid(
"all deleted accounts must exist and match Provider scope",
))
}
}
pub(crate) fn validate_credential_update(update: &ProviderCredentialUpdate) -> StoreResult<()> {
require_nonempty(ENTITY, "account_id", &update.account_id)?;
validate_object_size(
"provider_credentials_json",
&update.provider_credentials_json,
CREDENTIALS_MAX_BYTES,
)?;
if !update.has_refresh_token && update.next_refresh_at.is_some() {
return Err(invalid("next_refresh_at requires a refresh token"));
}
Ok(())
}
pub(crate) fn require_admin_account_changed(
rows_affected: u64,
account_id: &str,
) -> StoreResult<()> {
if rows_affected == 1 {
Ok(())
} else {
Err(StoreError::NotFound {
entity: ENTITY,
id: account_id.to_owned(),
})
}
}
pub(crate) fn validate_admin_account_ids(account_ids: &[String]) -> StoreResult<()> {
if account_ids.is_empty() || account_ids.len() > MAX_ADMIN_IMPORT_BATCH {
return Err(invalid(
"admin account selection must contain between 1 and 200 IDs",
));
}
let mut unique = BTreeSet::new();
for account_id in account_ids {
require_nonempty(ENTITY, "account_id", account_id)?;
if !unique.insert(account_id.as_str()) {
return Err(invalid("admin account selection contains duplicate IDs"));
}
}
Ok(())
}
pub(crate) async fn finish_admin_transaction<T>(
transaction: Transaction<'_, Postgres>,
result: StoreResult<T>,
operation: &'static str,
) -> StoreResult<T> {
match result {
Ok(value) => {
transaction
.commit()
.await
.map_err(|_| postgres_unavailable(operation))?;
Ok(value)
}
Err(error) => {
transaction
.rollback()
.await
.map_err(|_| postgres_unavailable(operation))?;
Err(error)
}
}
}
@@ -0,0 +1,524 @@
//! `provider_accounts` row 模型、解码与校验。
use super::*;
pub(crate) const ENTITY: &str = "provider account";
pub(crate) const CREDENTIALS_MAX_BYTES: usize = 256 * 1024;
pub(crate) const QUOTA_MAX_BYTES: usize = 128 * 1024;
pub(crate) const MAX_ADMIN_IMPORT_BATCH: usize = 200;
pub(crate) const ADMIN_USAGE_CHUNK_SIZE: usize = 200;
pub(crate) const ACCOUNT_USAGE_BY_WINDOWS_SQL: &str = "with requested_windows as (
select *
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
as requested(account_id, window_key, window_start, window_end)
)
select requested.account_id,
requested.window_key,
count(mr.id)::bigint as request_count,
count(mr.id) filter (where mr.outcome = 'succeeded')::bigint as success_count,
sum(mr.input_tokens)::bigint as input_tokens,
sum(mr.output_tokens)::bigint as output_tokens,
sum(mr.cached_tokens)::bigint as cached_tokens,
sum(mr.cache_write_tokens)::bigint as cache_write_tokens,
sum(mr.reasoning_tokens)::bigint as reasoning_tokens,
sum(mr.image_input_tokens)::bigint as image_input_tokens,
sum(mr.image_output_tokens)::bigint as image_output_tokens,
count(mr.id) filter (where mr.image_generation_succeeded is true)::bigint
as image_request_count,
count(mr.id) filter (where mr.image_generation_succeeded is false)::bigint
as image_request_failed_count,
coalesce(sum(coalesce(
mr.total_tokens,
coalesce(mr.input_tokens, 0) + coalesce(mr.output_tokens, 0)
)), 0)::bigint as total_tokens,
count(mr.id) filter (where mr.cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(mr.id) filter (where mr.cost_source = 'calculated')::bigint
as calculated_count,
count(mr.id) filter (where mr.cost_source = 'unavailable')::bigint
as unavailable_count,
max(mr.started_at) as last_used_at
from requested_windows requested
left join model_requests mr
on mr.provider_account_ref = requested.account_id
and mr.started_at >= requested.window_start
and mr.started_at < requested.window_end
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
group by requested.account_id, requested.window_key
order by requested.account_id, requested.window_key";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountAdminScope {
pub provider_kind: String,
}
impl ProviderAccountAdminScope {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)
}
fn contains(&self, account: &NewProviderAccount) -> bool {
account.provider_kind == self.provider_kind
}
}
/// 解析数据库 availability 列;字符串映射本体唯一归属 gateway-core。
pub(crate) fn parse_availability(value: &str) -> StoreResult<AccountAvailability> {
AccountAvailability::parse(value).ok_or_else(|| invalid("unknown availability value"))
}
pub(crate) fn validate_availability_cooldown(
availability: AccountAvailability,
has_cooldown_until: bool,
) -> StoreResult<()> {
if availability == AccountAvailability::Cooldown && !has_cooldown_until {
return Err(invalid("cooldown availability requires cooldown_until"));
}
if has_cooldown_until
&& !matches!(
availability,
AccountAvailability::Cooldown | AccountAvailability::QuotaExhausted
)
{
return Err(invalid(
"cooldown_until is only allowed for cooldown or quota_exhausted availability",
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountSummary {
pub id: String,
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
pub credential_revision: Revision,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
pub quota_observed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Clone, PartialEq)]
pub struct ProviderAccountRecord {
pub summary: ProviderAccountSummary,
pub provider_credentials_json: JsonObject,
pub provider_quota_json: Option<JsonObject>,
}
impl fmt::Debug for ProviderAccountRecord {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProviderAccountRecord")
.field("summary", &self.summary)
.field("provider_credentials_json", &"[REDACTED]")
.field(
"provider_quota_json",
&self.provider_quota_json.as_ref().map(|_| "[PROVIDER JSON]"),
)
.finish()
}
}
#[derive(Clone)]
pub struct NewProviderAccount {
pub id: String,
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
pub provider_credentials_json: JsonObject,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProviderAccount {
pub id: String,
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
}
impl fmt::Debug for NewProviderAccount {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("NewProviderAccount")
.field("id", &self.id)
.field("provider_kind", &self.provider_kind)
.field("name", &self.name)
.field("enabled", &self.enabled)
.field("availability", &self.availability)
.field("cooldown_until", &self.cooldown_until)
.field("provider_credentials_json", &"[REDACTED]")
.finish_non_exhaustive()
}
}
impl NewProviderAccount {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "id", &self.id)?;
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?;
require_nonempty(ENTITY, "name", &self.name)?;
require_nonempty(ENTITY, "upstream_user_id", &self.upstream_user_id)?;
require_nonempty(ENTITY, "authentication_kind", &self.authentication_kind)?;
if !self.has_refresh_token && self.next_refresh_at.is_some() {
return Err(invalid("next_refresh_at requires a refresh token"));
}
validate_availability_cooldown(self.availability, self.cooldown_until.is_some())?;
validate_object_size(
"provider_credentials_json",
&self.provider_credentials_json,
CREDENTIALS_MAX_BYTES,
)
}
}
#[derive(Clone)]
pub struct ImportProviderAccounts {
pub scope: ProviderAccountAdminScope,
pub accounts: Vec<NewProviderAccount>,
pub audit: AdminAuditEvent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountAdminImport {
pub config_revision: Revision,
pub account_ids: Vec<String>,
}
impl fmt::Debug for ImportProviderAccounts {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ImportProviderAccounts")
.field("scope", &self.scope)
.field(
"account_ids",
&self
.accounts
.iter()
.map(|account| account.id.as_str())
.collect::<Vec<_>>(),
)
.field("audit", &self.audit)
.finish()
}
}
impl ImportProviderAccounts {
pub fn validate(&self) -> StoreResult<()> {
self.scope.validate()?;
if self.accounts.is_empty() || self.accounts.len() > MAX_ADMIN_IMPORT_BATCH {
return Err(invalid(
"admin import batch must contain between 1 and 200 accounts",
));
}
let mut ids = BTreeSet::new();
for account in &self.accounts {
account.validate()?;
if !self.scope.contains(account) {
return Err(invalid(
"imported account is outside the Provider admin scope",
));
}
if !ids.insert(account.id.as_str()) {
return Err(invalid("admin import batch contains duplicate account IDs"));
}
}
Ok(())
}
}
#[derive(Clone)]
pub struct RotateProviderAccount {
pub scope: ProviderAccountAdminScope,
pub profile: UpdateProviderAccount,
pub replacement_identity: Option<ProviderAccountIdentity>,
pub credential: ProviderCredentialUpdate,
pub audit: AdminAuditEvent,
}
impl fmt::Debug for RotateProviderAccount {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RotateProviderAccount")
.field("scope", &self.scope)
.field("profile", &self.profile)
.field("replacement_identity", &self.replacement_identity)
.field("credential", &self.credential)
.field("audit", &self.audit)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct SetProviderAccountEnabled {
pub scope: ProviderAccountAdminScope,
pub account_id: String,
pub enabled: bool,
pub audit: AdminAuditEvent,
}
#[derive(Debug, Clone)]
pub struct DeleteProviderAccounts {
pub scope: ProviderAccountAdminScope,
pub account_ids: Vec<String>,
pub audit: AdminAuditEvent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderAccountAdminRotation {
pub config_revision: Revision,
pub credential_revision: Revision,
}
#[derive(Clone)]
pub struct ProviderCredentialUpdate {
pub account_id: String,
pub expected_revision: Revision,
pub provider_credentials_json: JsonObject,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
}
impl fmt::Debug for ProviderCredentialUpdate {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProviderCredentialUpdate")
.field("account_id", &self.account_id)
.field("expected_revision", &self.expected_revision)
.field("provider_credentials_json", &"[REDACTED]")
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderAccountObservation {
pub account_id: String,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub provider_quota_json: Option<JsonObject>,
pub availability_observed_at: DateTime<Utc>,
pub quota_observed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountStateUpdate {
pub account_id: String,
pub expected_revision: Revision,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
}
impl ProviderAccountStateUpdate {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "account_id", &self.account_id)?;
validate_availability_cooldown(self.availability, self.cooldown_until.is_some())?;
Ok(())
}
}
impl ProviderAccountObservation {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "account_id", &self.account_id)?;
validate_availability_cooldown(self.availability, self.cooldown_until.is_some())?;
if self.provider_quota_json.is_some() != self.quota_observed_at.is_some() {
return Err(invalid("quota JSON and quota_observed_at must agree"));
}
if let Some(quota) = &self.provider_quota_json {
validate_object_size("provider_quota_json", quota, QUOTA_MAX_BYTES)?;
}
Ok(())
}
}
pub(crate) const ACCOUNT_SELECT: &str = "select id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability,
availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
from provider_accounts where id = $1";
pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability,
availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
from provider_accounts
where id = any($1::text[]) and provider_kind = $2
order by id";
pub(crate) fn account_record_from_row(
row: sqlx::postgres::PgRow,
) -> StoreResult<ProviderAccountRecord> {
let credentials = JsonObject::try_from_value(
"provider_credentials_json",
row.try_get("provider_credentials_json")
.map_err(|_| invalid("invalid credentials JSON"))?,
CREDENTIALS_MAX_BYTES,
)?;
let quota = row
.try_get::<Option<serde_json::Value>, _>("provider_quota_json")
.map_err(|_| invalid("invalid quota JSON"))?
.map(|value| JsonObject::try_from_value("provider_quota_json", value, QUOTA_MAX_BYTES))
.transpose()?;
Ok(ProviderAccountRecord {
summary: account_summary_from_row(row)?,
provider_credentials_json: credentials,
provider_quota_json: quota,
})
}
pub(crate) fn core_account_from_summary(
summary: ProviderAccountSummary,
) -> Result<CoreProviderAccount, CoreStoreError> {
let id = CoreProviderAccountId::new(summary.id)
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let provider = ProviderKind::new(summary.provider_kind)
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let revision = CoreCredentialRevision::new(summary.credential_revision.get())
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
Ok(CoreProviderAccount::new(
id,
provider,
summary.name,
summary.upstream_user_id,
summary.authentication_kind,
revision,
summary.access_token_expires_at.map(Into::into),
)
.with_profile(
summary.email,
summary.upstream_account_id,
summary.plan_type,
)
.with_runtime_state(
summary.enabled,
summary.availability,
summary.cooldown_until.map(Into::into),
)
.with_refresh_schedule(
summary.has_refresh_token,
summary.next_refresh_at.map(Into::into),
))
}
pub(crate) fn core_store_error(error: StoreError) -> CoreStoreError {
let kind = match error {
StoreError::Unavailable { .. } => CoreStoreErrorKind::Unavailable,
StoreError::Conflict { .. } => CoreStoreErrorKind::Conflict,
StoreError::NotFound { .. } | StoreError::InvalidData { .. } => {
CoreStoreErrorKind::InvalidData
}
};
CoreStoreError::new(kind)
}
pub(crate) fn require_core_update(updated: bool) -> Result<(), CoreStoreError> {
if updated {
Ok(())
} else {
Err(CoreStoreError::new(CoreStoreErrorKind::InvalidState))
}
}
pub(crate) fn account_summary_from_row(
row: sqlx::postgres::PgRow,
) -> StoreResult<ProviderAccountSummary> {
let revision = row
.try_get::<i64, _>("credential_revision")
.map_err(|_| invalid("invalid credential revision"))?;
let availability = row
.try_get::<String, _>("availability")
.map_err(|_| invalid("invalid availability"))?;
Ok(ProviderAccountSummary {
id: get(&row, "id")?,
provider_kind: get(&row, "provider_kind")?,
name: get(&row, "name")?,
email: get(&row, "email")?,
upstream_user_id: get(&row, "upstream_user_id")?,
upstream_account_id: get(&row, "upstream_account_id")?,
plan_type: get(&row, "plan_type")?,
authentication_kind: get(&row, "authentication_kind")?,
credential_revision: Revision::new(to_u64(revision)?)?,
has_refresh_token: get(&row, "has_refresh_token")?,
access_token_expires_at: get(&row, "access_token_expires_at")?,
next_refresh_at: get(&row, "next_refresh_at")?,
enabled: get(&row, "enabled")?,
availability: parse_availability(&availability)?,
availability_reason: get(&row, "availability_reason")?,
cooldown_until: get(&row, "cooldown_until")?,
availability_observed_at: get(&row, "availability_observed_at")?,
quota_observed_at: get(&row, "quota_observed_at")?,
created_at: get(&row, "created_at")?,
updated_at: get(&row, "updated_at")?,
})
}
pub(crate) fn get<'r, T>(row: &'r sqlx::postgres::PgRow, column: &'static str) -> StoreResult<T>
where
T: sqlx::Decode<'r, sqlx::Postgres> + sqlx::Type<sqlx::Postgres>,
{
row.try_get(column).map_err(|_| invalid(column))
}
pub(crate) fn validate_object_size(
field: &'static str,
object: &JsonObject,
max: usize,
) -> StoreResult<()> {
let size = serde_json::to_vec(&object.as_value())
.map_err(|error| StoreError::InvalidData {
entity: ENTITY,
message: error.to_string(),
})?
.len();
if size > max {
Err(invalid(field))
} else {
Ok(())
}
}
pub(crate) fn to_i64(value: u64) -> StoreResult<i64> {
i64::try_from(value).map_err(|_| invalid("revision is too large"))
}
pub(crate) fn to_u64(value: i64) -> StoreResult<u64> {
u64::try_from(value).map_err(|_| invalid("revision must be positive"))
}
pub(crate) fn invalid(message: &str) -> StoreError {
StoreError::InvalidData {
entity: ENTITY,
message: message.to_owned(),
}
}
+262
View File
@@ -0,0 +1,262 @@
//! Store 值类型、错误与跨层映射。
use super::*;
/// 发生错误的基础设施边界。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreBackend {
PostgreSql,
Redis,
}
/// 上层状态机需要区分的稳定冲突类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictKind {
StaleRevision,
AlreadyFinalized,
DownstreamAlreadyCommitted,
RequestNotRunning,
InvalidTransition,
LeaseLost,
FencingTokenStale,
}
/// Store adapter 的稳定错误边界。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum StoreError {
#[error("{backend:?} store is unavailable: {message}")]
Unavailable {
backend: StoreBackend,
message: String,
},
#[error("{entity} {id} was not found")]
NotFound { entity: &'static str, id: String },
#[error("store conflict for {entity} {id}: {kind:?}")]
Conflict {
entity: &'static str,
id: String,
kind: ConflictKind,
},
#[error("invalid persisted {entity}: {message}")]
InvalidData {
entity: &'static str,
message: String,
},
}
pub type StoreResult<T> = Result<T, StoreError>;
pub(crate) fn store_revision(revision: AdminRevision) -> AdminStoreResult<Revision> {
Revision::new(revision.get()).map_err(|error| admin_store_error("config revision", error))
}
pub(crate) fn admin_revision(revision: Revision) -> AdminStoreResult<AdminRevision> {
AdminRevision::new(revision.get()).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
"config revision",
"config revision is invalid",
)
})
}
pub(crate) fn mutation_audit(
context: &MutationContext,
action: &str,
entity_kind: &str,
entity_ref: &str,
changed_fields: Vec<String>,
) -> postgres::AdminAuditEvent {
let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor {
MutationActor::AdminSession { admin_user_id } => (
postgres::AdminAuditActorKind::AdminSession,
Some(admin_user_id.clone()),
admin_user_id.clone(),
),
MutationActor::AdminApiKey => (
postgres::AdminAuditActorKind::AdminApiKey,
None,
"admin_api_key".to_owned(),
),
MutationActor::System => (
postgres::AdminAuditActorKind::System,
None,
"system".to_owned(),
),
};
postgres::AdminAuditEvent {
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
actor_kind,
actor_admin_user_id,
actor_ref,
admin_request_id: Some(context.request_id.clone()),
action: action.to_owned(),
entity_kind: entity_kind.to_owned(),
entity_ref: entity_ref.to_owned(),
config_revision: None,
changed_fields,
created_at: chrono::Utc::now(),
}
}
pub(crate) fn admin_store_error(resource: &'static str, error: StoreError) -> AdminStoreError {
let kind = match error {
StoreError::NotFound { .. } => AdminStoreErrorKind::NotFound,
StoreError::Conflict {
kind: ConflictKind::StaleRevision,
..
} => AdminStoreErrorKind::StaleRevision,
StoreError::Conflict { .. } => AdminStoreErrorKind::Conflict,
StoreError::InvalidData { .. } => AdminStoreErrorKind::Invalid,
StoreError::Unavailable { .. } => AdminStoreErrorKind::Unavailable,
};
AdminStoreError::new(kind, resource, "store operation failed")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Revision(NonZeroU64);
impl Revision {
pub fn new(value: u64) -> StoreResult<Self> {
NonZeroU64::new(value)
.map(Self)
.ok_or_else(|| StoreError::InvalidData {
entity: "revision",
message: "must be greater than zero".to_owned(),
})
}
#[must_use]
pub const fn get(self) -> u64 {
self.0.get()
}
}
/// `numeric(20,10)` 可无损表达的非负金额。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DecimalAmount(String);
impl DecimalAmount {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DecimalAmount {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl FromStr for DecimalAmount {
type Err = StoreError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let input = input.trim();
let mut parts = input.split('.');
let whole = parts.next().unwrap_or_default();
let fraction = parts.next();
let valid = !whole.is_empty()
&& whole.len() <= 10
&& whole.bytes().all(|byte| byte.is_ascii_digit())
&& parts.next().is_none()
&& fraction.is_none_or(|value| {
!value.is_empty()
&& value.len() <= 10
&& value.bytes().all(|byte| byte.is_ascii_digit())
});
if !valid {
return Err(StoreError::InvalidData {
entity: "decimal amount",
message: "expected a non-negative numeric(20,10) value".to_owned(),
});
}
let whole = whole.trim_start_matches('0');
let whole = if whole.is_empty() { "0" } else { whole };
let fraction = fraction.unwrap_or_default().trim_end_matches('0');
let canonical = if fraction.is_empty() {
whole.to_owned()
} else {
format!("{whole}.{fraction}")
};
Ok(Self(canonical))
}
}
/// Provider-owned JSON object。Store 只验证 object 与大小,不解释内部 key。
#[derive(Clone, PartialEq)]
pub struct JsonObject(Map<String, Value>);
impl JsonObject {
pub fn try_from_value(
entity: &'static str,
value: Value,
max_serialized_bytes: usize,
) -> StoreResult<Self> {
let serialized_bytes = serde_json::to_vec(&value)
.map_err(|error| StoreError::InvalidData {
entity,
message: error.to_string(),
})?
.len();
let Value::Object(fields) = value else {
return Err(StoreError::InvalidData {
entity,
message: "top-level JSON value must be an object".to_owned(),
});
};
if serialized_bytes > max_serialized_bytes {
return Err(StoreError::InvalidData {
entity,
message: format!("serialized JSON exceeds {max_serialized_bytes} bytes"),
});
}
Ok(Self(fields))
}
#[must_use]
pub fn as_value(&self) -> Value {
Value::Object(self.0.clone())
}
#[must_use]
pub fn fields(&self) -> &Map<String, Value> {
&self.0
}
}
impl fmt::Debug for JsonObject {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("JsonObject([REDACTED])")
}
}
pub(crate) fn require_nonempty(
entity: &'static str,
field: &'static str,
value: &str,
) -> StoreResult<()> {
if value.trim().is_empty() {
Err(StoreError::InvalidData {
entity,
message: format!("{field} must not be empty"),
})
} else {
Ok(())
}
}
pub(crate) fn postgres_unavailable(operation: &'static str) -> StoreError {
StoreError::Unavailable {
backend: StoreBackend::PostgreSql,
message: operation.to_owned(),
}
}
pub(crate) fn redis_unavailable(operation: &'static str) -> StoreError {
StoreError::Unavailable {
backend: StoreBackend::Redis,
message: operation.to_owned(),
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Worker 贡献、调度定义与健康探针。
use super::*;
pub(crate) fn store_worker_contributions(
execution: Arc<postgres::PgExecutionStore>,
execution_writer: postgres::ExecutionObservationWriter<postgres::PgExecutionStore>,
admission_release_writer: redis::ClientAdmissionReleaseWriter,
circuit_feedback_writer: redis::ProviderCircuitFeedbackWriter,
retention: Arc<postgres::PgRetentionRepository>,
) -> StoreResult<Vec<WorkerContribution>> {
let stale_id = WorkerId::try_new(WorkerKind::StaleModelRequestRecovery, "postgres")
.map_err(worker_definition_error)?;
let retention_id =
WorkerId::try_new(WorkerKind::Retention, "postgres").map_err(worker_definition_error)?;
let ops_flush_id =
WorkerId::try_new(WorkerKind::OpsFlush, "postgres").map_err(worker_definition_error)?;
let admission_flush_id = WorkerId::try_new(WorkerKind::OpsFlush, "redis_admission")
.map_err(worker_definition_error)?;
let circuit_flush_id = WorkerId::try_new(WorkerKind::OpsFlush, "redis_circuit")
.map_err(worker_definition_error)?;
let ops_flush_restart =
DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(worker_definition_error)?;
Ok(vec![
WorkerContribution::Registration(scheduled_worker(
stale_id,
Duration::from_secs(30),
Box::new(StaleModelRequestRecoveryTask { execution }),
)?),
WorkerContribution::Registration(scheduled_worker(
retention_id,
Duration::from_secs(60 * 60),
Box::new(RetentionTask { retention }),
)?),
WorkerContribution::Registration(
WorkerRegistration::try_new(
ops_flush_id,
WorkerRunnable::Daemon {
restart: ops_flush_restart,
task: Box::new(execution_writer),
},
)
.map_err(worker_definition_error)?,
),
WorkerContribution::Registration(
WorkerRegistration::try_new(
admission_flush_id,
WorkerRunnable::Daemon {
restart: ops_flush_restart,
task: Box::new(admission_release_writer),
},
)
.map_err(worker_definition_error)?,
),
WorkerContribution::Registration(
WorkerRegistration::try_new(
circuit_flush_id,
WorkerRunnable::Daemon {
restart: ops_flush_restart,
task: Box::new(circuit_feedback_writer),
},
)
.map_err(worker_definition_error)?,
),
])
}
pub(crate) fn scheduled_worker(
id: WorkerId,
interval: Duration,
task: Box<dyn ScheduledTask>,
) -> StoreResult<WorkerRegistration> {
let schedule = WorkerSchedule::try_new(
interval,
Duration::from_secs(1),
Duration::from_secs(60),
Duration::from_secs(15 * 60),
Duration::from_secs(5 * 60),
)
.map_err(worker_definition_error)?;
let lease = WorkerLeaseRequest::try_new(id.clone(), schedule.leader_lease_ttl())
.map_err(worker_definition_error)?;
WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task,
},
)
.map_err(worker_definition_error)
}
pub(crate) fn worker_definition_error(
error: gateway_core::task::WorkerDefinitionError,
) -> StoreError {
StoreError::InvalidData {
entity: "store worker plan",
message: error.to_string(),
}
}
pub(crate) struct StaleModelRequestRecoveryTask {
execution: Arc<postgres::PgExecutionStore>,
}
impl ScheduledTask for StaleModelRequestRecoveryTask {
fn run_cycle(
&self,
_context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
gateway_core::engine::ExecutionStore::recover_expired(
self.execution.as_ref(),
SystemTime::now(),
)
.await
.map(|_| ())
.map_err(|_| WorkerTaskError::safe("stale request recovery failed"))
})
}
}
pub(crate) struct RetentionTask {
retention: Arc<postgres::PgRetentionRepository>,
}
impl ScheduledTask for RetentionTask {
fn run_cycle(
&self,
_context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let settings =
postgres::RetentionRepository::load_retention_settings(self.retention.as_ref())
.await
.map_err(|_| WorkerTaskError::safe("retention settings read failed"))?;
postgres::RetentionRepository::apply_retention(
self.retention.as_ref(),
chrono::Utc::now(),
settings,
)
.await
.map(|_| ())
.map_err(|_| WorkerTaskError::safe("retention cleanup failed"))
})
}
}
pub(crate) struct PostgresHealthProbe {
pub(crate) pool: sqlx::PgPool,
}
impl HealthProbe for PostgresHealthProbe {
fn name(&self) -> &'static str {
"postgres"
}
fn check(&self) -> futures::future::BoxFuture<'_, HealthState> {
Box::pin(async move {
match sqlx::query_scalar::<_, i32>("select 1")
.fetch_one(&self.pool)
.await
{
Ok(1) => HealthState::Healthy,
Ok(_) => HealthState::Unhealthy("PostgreSQL health result is invalid".to_owned()),
Err(_) => HealthState::Unhealthy("PostgreSQL is unavailable".to_owned()),
}
})
}
}
pub(crate) struct RedisHealthProbe {
pub(crate) connection: ::redis::aio::ConnectionManager,
}
impl HealthProbe for RedisHealthProbe {
fn name(&self) -> &'static str {
"redis"
}
fn check(&self) -> futures::future::BoxFuture<'_, HealthState> {
Box::pin(async move {
let mut connection = self.connection.clone();
match ::redis::cmd("PING")
.query_async::<String>(&mut connection)
.await
{
Ok(response) if response == "PONG" => HealthState::Healthy,
Ok(_) => HealthState::Unhealthy("Redis health result is invalid".to_owned()),
Err(_) => HealthState::Unhealthy("Redis is unavailable".to_owned()),
}
})
}
}