refactor(backend): centralize shared models and settings validation

Reuse domain models for observability, account statistics, execution facts,
and group validation. Compose shared settings values while preserving
HTTP and SDK contracts and explicit storage and runtime boundaries.

Verify serialization, concurrency, persistence, and snapshot behavior with
3528 backend tests, strict Clippy, formatting, and architecture checks.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-07 02:32:53 +08:00
co-authored by Codex
parent cb7daffb66
commit 0bfeff28fc
65 changed files with 1879 additions and 2522 deletions
@@ -66,13 +66,13 @@ impl FreezeRecoveryTask {
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
settings.account_auto_freeze_enabled,
settings.account_auto_freeze_threshold,
settings.account_auto_freeze_window_seconds,
settings.account_auto_freeze_duration_seconds,
settings.account_auto_freeze_probe_enabled,
settings.account_auto_freeze_probe_model,
settings.account_auto_freeze_adaptive_concurrency,
settings.values.account_auto_freeze_enabled,
settings.values.account_auto_freeze_threshold,
settings.values.account_auto_freeze_window_seconds,
settings.values.account_auto_freeze_duration_seconds,
settings.values.account_auto_freeze_probe_enabled,
settings.values.account_auto_freeze_probe_model,
settings.values.account_auto_freeze_adaptive_concurrency,
)
.ok()
}
@@ -10,6 +10,23 @@ use gateway_core::{
use super::{PageSize, Revision, observability::DecimalAmount};
/// 名称与描述的共同约束,HTTP、用例和存储边界只映射错误
pub fn validate_group_fields(
name: &str,
description: Option<&str>,
) -> Result<(), super::AdminModelError> {
if name.trim() != name
|| name.is_empty()
|| name.chars().count() > 100
|| name.chars().any(char::is_control)
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(super::AdminModelError::InvalidAccountGroupFields);
}
Ok(())
}
/// 账号分组持久化使用的标准 `#RRGGBBAA` 颜色
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupColor(String);
@@ -133,40 +133,10 @@ pub struct AccountRecord {
pub updated_at: DateTime<Utc>,
}
/// 单一货币的账号成本聚合
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountCost {
pub currency: String,
pub amount: super::observability::DecimalAmount,
}
/// 账号在一个模型上的历史用量
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountModelUsage {
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: super::observability::CostCoverage,
pub costs: Vec<AccountCost>,
pub last_used_at: DateTime<Utc>,
}
/// 账号在一个小时窗口内的请求数
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
pub use super::observability::{
AccountModelUsage, AccountPoolMetrics as AccountSummary, AccountRequestBucket,
CurrencyCost as AccountCost,
};
/// 账号历史用量聚合
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -235,19 +205,6 @@ pub struct AccountCapacity {
pub total_slots: Option<u64>,
}
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响
///
/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSummary {
pub total: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
/// 账号可编辑事实的一次性替换命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
@@ -20,6 +20,20 @@ pub type ProviderRequestProfileOverrides =
pub type ProviderRequestProfileOverrideUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// Client Key 关联分组的数量与唯一性约束
pub fn validate_group_ids(group_ids: &[AccountGroupId]) -> Result<(), AdminModelError> {
if group_ids.len() > 1000
|| group_ids
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= group_ids.len()
{
return Err(AdminModelError::InvalidClientKeyGroups);
}
Ok(())
}
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClientKeyPageSize(NonZeroU16);
@@ -1,10 +1,10 @@
//! 单页 Key 用量的查询合同;身份范围不由调用方提供
use super::{
PageSize,
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
UsageOverview, UsagePage,
},
};
@@ -25,7 +25,7 @@ pub struct KeyUsageRecordsQuery {
pub usage: KeyUsageQuery,
pub kind: KeyUsageRecordKind,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
pub struct KeyUsageOverview {
@@ -215,6 +215,12 @@ pub enum AdminModelError {
ZeroRevision,
#[error("page size {0} is outside 1..=200")]
InvalidPageSize(u16),
#[error("observability page size {0} is outside 1..=100")]
InvalidObservabilityPageSize(u16),
#[error("account group name or description is invalid")]
InvalidAccountGroupFields,
#[error("client key group IDs are duplicated or exceed 1000 entries")]
InvalidClientKeyGroups,
#[error("client key page size must be inside 1..=65535")]
InvalidClientKeyPageSize,
#[error("request outcome must be 1..=256 bytes without control characters")]
@@ -1,10 +1,32 @@
//! 用量、成本、健康与错误诊断的 UTC 语义事实
use std::str::FromStr;
use std::{num::NonZeroU16, str::FromStr};
use chrono::{DateTime, TimeDelta, Utc};
use super::{AdminModelError, PageSize};
use super::AdminModelError;
/// 观测记录查询的页大小,由 HTTP、管理用例和存储端口共同使用
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObservabilityPageSize(NonZeroU16);
impl ObservabilityPageSize {
pub const MAX: u16 = 100;
pub fn new(value: u16) -> Result<Self, AdminModelError> {
if value > Self::MAX {
return Err(AdminModelError::InvalidObservabilityPageSize(value));
}
NonZeroU16::new(value)
.map(Self)
.ok_or(AdminModelError::InvalidObservabilityPageSize(value))
}
#[must_use]
pub const fn get(self) -> u16 {
self.0.get()
}
}
/// 页面筛选表达自然日范围,具体 UTC 边界由部署时区解析
#[derive(Debug, Clone, Copy)]
@@ -152,7 +174,7 @@ pub struct UsageQuery {
pub range: TimeRange,
pub filter: UsageFilter,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
/// 运维错误过滤条件
@@ -178,7 +200,7 @@ pub struct OpsErrorQuery {
pub range: TimeRange,
pub filter: OpsErrorFilter,
pub current_page: u32,
pub page_size: PageSize,
pub page_size: ObservabilityPageSize,
}
/// 用量诊断维度
@@ -220,7 +242,7 @@ impl DecimalAmount {
}
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
Some(Self(value.canonical()))
Self::from_str(&value.canonical()).ok()
}
}
@@ -466,7 +488,7 @@ pub struct RequestMetricPoint {
}
/// 账号池的统一五态统计
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountPoolMetrics {
pub total: u64,
pub normal: u64,
@@ -476,9 +498,9 @@ pub struct AccountPoolMetrics {
pub error: u64,
}
/// Dashboard 中一个账号的模型级用量事实
/// 账号在查询时间范围内的模型级用量事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardAccountModelUsage {
pub struct AccountModelUsage {
pub model: String,
pub request_count: u64,
pub success_count: u64,
@@ -497,9 +519,9 @@ pub struct DashboardAccountModelUsage {
pub last_used_at: DateTime<Utc>,
}
/// Dashboard 中账号的单小时请求数
/// 账号在单小时窗口内的请求数
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardAccountRequestBucket {
pub struct AccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
@@ -530,14 +552,14 @@ pub struct DashboardAccountUsage {
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
pub last_used_at: Option<DateTime<Utc>>,
pub request_buckets: Vec<DashboardAccountRequestBucket>,
pub request_buckets: Vec<AccountRequestBucket>,
/// Provider 已持久化额度窗口投影出的代表性已用比例
///
/// `None` 表示上游未提供可比较的百分比,不应伪造为零
pub quota_used_percent: Option<f64>,
/// Provider 返回的代表窗口;缺失时保留未知语义
pub quota_window: Option<super::provider_credentials::ProviderQuotaWindow>,
pub models: Vec<DashboardAccountModelUsage>,
pub models: Vec<AccountModelUsage>,
}
/// Dashboard 当前账号池的可重建运行时槽位事实
@@ -552,7 +574,7 @@ pub struct DashboardRuntimeSlots {
}
/// 仪表盘卡片脚注展示的全历史累计
#[derive(Debug, Clone, Default, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DashboardTotals {
pub request_count: u64,
pub input_tokens: u64,
@@ -17,8 +17,26 @@ pub struct RetentionPolicy {
}
impl RetentionPolicy {
/// 保留期管理与全量设置保存共用的字段约束
pub fn validate_values(
usage_days: u32,
ops_days: u32,
audit_days: u32,
) -> Result<(), &'static str> {
for (valid, field) in [
(usage_days >= 31, "usage_retention_days"),
(ops_days > 0, "ops_event_retention_days"),
(audit_days > 0, "audit_retention_days"),
] {
if !valid {
return Err(field);
}
}
Ok(())
}
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
if Self::validate_values(usage_days, ops_days, audit_days).is_err() {
return Err(AdminError::invalid(
"请求保留期至少 31 天,事件与审计保留期必须为正数",
));
@@ -11,6 +11,26 @@ use super::Revision;
/// 客户端模型到上游模型的全局精确映射
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
/// 全局模型映射的数量与文本约束;调用方保留各自的键值表示
pub fn validate_model_mappings<'a>(
mappings: impl ExactSizeIterator<Item = (&'a str, &'a str)>,
) -> Result<(), &'static str> {
if mappings.len() > 512 {
return Err("model_mappings");
}
for (requested, upstream) in mappings {
for value in [requested, upstream] {
if value.is_empty()
|| value.len() > 256
|| value.bytes().any(|byte| byte.is_ascii_control())
{
return Err("model_mappings");
}
}
}
Ok(())
}
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间
pub type ProviderRequestProfiles =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
@@ -22,15 +42,11 @@ pub type ProviderRequestProfileUpdates =
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实
/// 运行设置的共同值;版本、Provider 画像更新和数据库秘密由外层类型拥有
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub struct RuntimeSettingsValues {
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
@@ -44,7 +60,6 @@ pub struct RuntimeSettings {
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
@@ -60,6 +75,18 @@ pub struct RuntimeSettings {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 完整运行设置事实
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
#[serde(flatten)]
pub values: RuntimeSettingsValues,
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub model_mappings: ModelMappings,
pub rotation_strategy: RotationStrategy,
pub updated_at: DateTime<Utc>,
}
@@ -67,42 +94,14 @@ pub struct RuntimeSettings {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceRuntimeSettings {
#[serde(flatten)]
pub values: RuntimeSettingsValues,
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值
pub request_profile_updates: ProviderRequestProfileUpdates,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏
@@ -180,39 +179,63 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
.into_iter()
.map(|(provider, value)| (provider, Some(value)))
.collect(),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
values: settings.values,
model_mappings: settings.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,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
max_account_rotations: settings.max_account_rotations,
openai_session_affinity_ttl_hours: settings.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
}
}
}
impl RuntimeSettingsValues {
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
pub fn validate(&self) -> Result<(), &'static str> {
self.request_location
.validate()
.map_err(|_| "request_location")?;
gateway_core::settings::validate_request_limits(
self.max_waiting_per_key,
self.max_waiting_per_account,
self.concurrency_wait_timeout_seconds,
self.max_account_rotations,
self.openai_session_affinity_ttl_hours,
)?;
gateway_core::settings::response_body_limit(self.responses_max_decompressed_body_bytes)?;
gateway_core::settings::client_min_versions(
self.min_codex_desktop_version.as_deref(),
self.min_codex_cli_version.as_deref(),
)?;
for (valid, field) in [
(
self.refresh_margin_seconds > 0
&& i64::try_from(self.refresh_margin_seconds).is_ok(),
"refresh_margin_seconds",
),
(self.refresh_concurrency > 0, "refresh_concurrency"),
(
i64::try_from(self.request_interval_ms).is_ok(),
"request_interval_ms",
),
] {
if !valid {
return Err(field);
}
}
super::retention::RetentionPolicy::validate_values(
self.usage_retention_days,
self.ops_event_retention_days,
self.audit_retention_days,
)?;
gateway_core::provider_ports::ProviderFreezePolicy::validate_values(
self.account_auto_freeze_threshold,
self.account_auto_freeze_window_seconds,
self.account_auto_freeze_duration_seconds,
self.account_auto_freeze_probe_model.as_deref(),
)?;
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
self.account_warmup_enabled,
&self.account_warmup_schedule_time,
self.account_warmup_model.as_deref(),
)?;
Ok(())
}
}
@@ -135,6 +135,11 @@ impl AccountGroupService for DefaultAccountGroupService {
context: &MutationContext,
command: CreateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
crate::model::account_groups::validate_group_fields(
&command.name,
command.description.as_deref(),
)
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
self.publish(
@@ -159,6 +164,11 @@ impl AccountGroupService for DefaultAccountGroupService {
context: &MutationContext,
command: UpdateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
crate::model::account_groups::validate_group_fields(
&command.name,
command.description.as_deref(),
)
.map_err(|_| AdminError::invalid("账号组请求不合法"))?;
self.publish(self.store.update_account_group(command, context).await)
.await
}
@@ -157,6 +157,8 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_overrides {
self.providers
.require(provider)
@@ -199,6 +201,8 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_override_updates {
if let Some(profile) = profile {
self.providers
@@ -204,7 +204,7 @@ impl DefaultObservabilityService {
let wire_profiles = self
.providers
.dashboard_wire_profiles(&settings.request_profiles);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let max_concurrent_per_account = u64::from(settings.values.max_concurrent_per_account);
let (inherited_accounts, overridden_slots) = runtime_slots
.as_ref()
.map_or((observation.provider_accounts.normal, 0), |slots| {
@@ -3,7 +3,6 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::policy::CodexClientVersion;
use gateway_core::runtime::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
@@ -103,53 +102,17 @@ impl DefaultSettingsService {
}
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_account_rotations <= gateway_core::account::MAX_ACCOUNT_ROTATIONS
&& (1..=gateway_core::account::MAX_SESSION_AFFINITY_TTL_HOURS)
.contains(&command.openai_session_affinity_ttl_hours)
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
&& crate::model::retention::RetentionPolicy::try_new(
command.usage_retention_days,
command.ops_event_retention_days,
command.audit_retention_days,
)
.is_ok()
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
&& gateway_core::provider_ports::valid_warmup_schedule_time(
&command.account_warmup_schedule_time,
)
&& valid_probe_model(command.account_warmup_model.as_deref())
&& (!command.account_warmup_enabled || command.account_warmup_model.is_some())
&& i64::try_from(command.request_interval_ms).is_ok()
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
if valid {
Ok(())
} else {
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
fn valid_probe_model(value: Option<&str>) -> bool {
value.is_none_or(|value| {
!value.is_empty()
&& value.len() <= 128
&& value == value.trim()
&& !value.bytes().any(|byte| byte.is_ascii_control())
})
crate::model::settings::validate_model_mappings(
command
.model_mappings
.iter()
.map(|(requested, upstream)| (requested.as_str(), upstream.as_str())),
)
.map_err(|_| AdminError::invalid("运行时设置不满足约束"))?;
command
.values
.validate()
.map_err(|_| AdminError::invalid("运行时设置不满足约束"))
}
#[async_trait]
@@ -0,0 +1,17 @@
//! 验证分组文本共用规则中的字符数、字节数和控制字符边界
use gateway_admin::model::account_groups::validate_group_fields;
#[test]
fn group_fields_distinguish_name_characters_from_description_bytes() {
let name = "组".repeat(100);
let description = "é".repeat(2048);
assert!(validate_group_fields(&name, Some(&description)).is_ok());
assert!(validate_group_fields(&"组".repeat(101), None).is_err());
assert!(validate_group_fields("组", Some(&"é".repeat(2049))).is_err());
for name in ["", " 组", "组 ", "组\n名"] {
assert!(validate_group_fields(name, None).is_err());
}
assert!(validate_group_fields("组", Some("含\t制表符")).is_err());
assert!(validate_group_fields("组", Some("")).is_ok());
}
@@ -0,0 +1,16 @@
//! 验证 Client Key 分组集合的共同上限与唯一性规则
use gateway_admin::model::client_keys::validate_group_ids;
use gateway_core::routing::AccountGroupId;
#[test]
fn client_key_groups_reject_duplicates_and_more_than_one_thousand_ids() {
assert!(validate_group_ids(&[]).is_ok());
let mut groups: Vec<_> = (0..1000)
.map(|index| AccountGroupId::new(format!("grp_{index:032x}")).unwrap())
.collect();
assert!(validate_group_ids(&groups).is_ok());
groups.push(AccountGroupId::new("grp_ffffffffffffffffffffffffffffffff").unwrap());
assert!(validate_group_ids(&groups).is_err());
assert!(validate_group_ids(&[groups[0].clone(), groups[0].clone()]).is_err());
}
@@ -1,7 +1,10 @@
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块
mod account_groups;
mod audit;
mod backup;
mod client_keys;
mod observability;
mod plugins;
mod provider_credentials;
mod quota_forecast;
@@ -0,0 +1,67 @@
//! 验证观测查询和数值类型在 HTTP、管理及持久化边界共用的约束
use gateway_admin::model::{
PageSize,
observability::{DecimalAmount, ObservabilityPageSize, PercentileMilliseconds},
};
#[test]
fn observability_page_size_has_its_own_limit() {
for value in [1, 100] {
assert_eq!(ObservabilityPageSize::new(value).unwrap().get(), value);
}
for value in [0, 101, 150, 200, u16::MAX] {
assert!(ObservabilityPageSize::new(value).is_err(), "{value}");
}
assert!(PageSize::new(200).is_ok());
}
#[test]
fn decimal_amount_preserves_numeric_precision_and_normalization() {
for (input, expected) in [
("0", "0"),
(" 00012.34000 ", "12.34"),
("00000.00000", "0"),
("9999999999.9999999999", "9999999999.9999999999"),
("0.0000000001", "0.0000000001"),
] {
assert_eq!(input.parse::<DecimalAmount>().unwrap().as_str(), expected);
}
for input in [
"",
"-1",
"+1",
"NaN",
"inf",
"1e3",
".1",
"1.",
"1.2.3",
"10000000000",
"0.00000000001",
"1",
] {
assert!(input.parse::<DecimalAmount>().is_err(), "{input:?}");
}
}
#[test]
fn decimal_arithmetic_keeps_the_shared_storage_precision() {
let max: DecimalAmount = "9999999999.9999999999".parse().unwrap();
let unit: DecimalAmount = "0.0000000001".parse().unwrap();
assert!(max.checked_add(&unit).is_none());
let one: DecimalAmount = "1".parse().unwrap();
assert_eq!(one.checked_div_u64(3).unwrap().as_str(), "0.3333333333");
assert!(one.checked_div_u64(0).is_none());
assert_eq!(one.checked_add(&unit).unwrap().as_str(), "1.0000000001");
}
#[test]
fn percentile_preserves_interpolation_and_rejects_invalid_values() {
for value in [0.0, 0.125, 1.5, 95.75] {
assert_eq!(PercentileMilliseconds::new(value).unwrap().as_f64(), value);
}
for value in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(PercentileMilliseconds::new(value).is_err());
}
}
@@ -297,3 +297,46 @@ fn unused() -> AdminStoreError {
"unused test operation",
)
}
#[tokio::test]
async fn invalid_group_fields_are_rejected_before_store_mutations() {
use gateway_admin::model::{AdminErrorKind, MutationActor, account_groups::CreateAccountGroup};
let service = AdminHarness::new()
.account_groups(Arc::new(FakeGroupStore::default()))
.build()
.await;
let context = MutationContext {
actor: MutationActor::System,
request_id: "invalid-group".to_owned(),
};
let record = group_record();
let created = service
.account_groups()
.create(
&context,
CreateAccountGroup {
fast_mode: FastMode::Default,
name: " invalid".to_owned(),
description: None,
color: record.color.clone(),
},
)
.await
.unwrap_err();
let updated = service
.account_groups()
.update(
&context,
UpdateAccountGroup {
fast_mode: None,
id: record.id,
name: "valid".to_owned(),
description: Some("invalid\n".to_owned()),
color: record.color,
},
)
.await
.unwrap_err();
assert_eq!(created.kind(), AdminErrorKind::Invalid);
assert_eq!(updated.kind(), AdminErrorKind::Invalid);
}
@@ -1110,40 +1110,42 @@ impl SettingsStore for StaticSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
})
}
@@ -414,3 +414,41 @@ fn unused() -> AdminStoreError {
"unused in this test",
)
}
#[tokio::test]
async fn duplicate_group_ids_are_rejected_before_key_creation_or_update() {
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
let group =
gateway_core::routing::AccountGroupId::new("grp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
let mut command = create_command(None);
command.group_ids = vec![group.clone(), group.clone()];
let created = services
.client_keys()
.create(&mutation_context(), command)
.await
.unwrap_err();
let updated = services
.client_keys()
.update(
&mutation_context(),
UpdateClientKey {
request_profile_override_updates: Default::default(),
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
group_ids: vec![group.clone(), group],
limits: RateLimits::unlimited(),
daily_limit_usd: None,
weekly_limit_usd: None,
},
)
.await
.unwrap_err();
assert_eq!(created.kind(), AdminErrorKind::Invalid);
assert_eq!(updated.kind(), AdminErrorKind::Invalid);
assert!(store.plaintexts.lock().unwrap().is_empty());
}
@@ -26,40 +26,42 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
RuntimeSettings {
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 5,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: enabled,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: probe_enabled,
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
account_auto_freeze_adaptive_concurrency: adaptive,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 5,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: enabled,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: probe_enabled,
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
account_auto_freeze_adaptive_concurrency: adaptive,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
}
}
@@ -15,15 +15,15 @@ use chrono::{DateTime, Duration, Timelike as _, Utc};
use gateway_admin::{
AdminServices,
model::{
MutationContext, PageSize, Revision,
MutationContext, Revision,
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardAccountUsage,
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension,
DiagnosticObservation, DiagnosticsObservation, Granularity, HealthStatus,
LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
UsagePage, UsageQuery,
LatencyPercentiles, ObservabilityPageSize, OpsErrorPage, OpsErrorQuery,
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind,
UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord,
UsageOverview, UsagePage, UsageQuery,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -989,40 +989,42 @@ impl SettingsStore for FixtureSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(1).expect("revision"),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: self.max_concurrent_per_account,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
updated_at: Utc::now(),
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: self.max_concurrent_per_account,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
})
}
@@ -1087,7 +1089,7 @@ fn usage_query(now: DateTime<Utc>) -> UsageQuery {
range: observation_range(now),
filter: UsageFilter::default(),
current_page: 1,
page_size: PageSize::new(50).expect("page size"),
page_size: ObservabilityPageSize::new(50).expect("page size"),
}
}
@@ -163,7 +163,37 @@ fn sdk_settings_contract_matches_host_declarations() {
_ => false,
})
.unwrap_or_else(|| panic!("缺少合同类型 {name}"));
if let Item::Struct(item) = item
let mut item = item.clone();
// SDK 保持独立的平铺合同,不暴露宿主内部的值对象组合
if matches!(*name, "RuntimeSettings" | "ReplaceRuntimeSettings") {
let values = items
.iter()
.find_map(|item| match item {
Item::Struct(item) if item.ident == "RuntimeSettingsValues" => {
Some(&item.fields)
}
_ => None,
})
.expect("settings values");
let Item::Struct(record) = &mut item else {
unreachable!()
};
let syn::Fields::Named(fields) = &mut record.fields else {
unreachable!()
};
fields.named = fields
.named
.iter()
.flat_map(|field| {
if field.ident.as_ref().is_some_and(|ident| ident == "values") {
values.iter().cloned().collect::<Vec<_>>()
} else {
vec![field.clone()]
}
})
.collect();
}
if let Item::Struct(item) = &item
&& item.ident == "ReplaceRuntimeSettings"
{
replacement_fields = item
@@ -173,7 +203,7 @@ fn sdk_settings_contract_matches_host_declarations() {
.collect();
}
types.push(contract_type(
item.clone(),
item,
if *name == "SmartSchedulingValues" {
"SmartSchedulingConfig"
} else {
@@ -277,38 +277,40 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
ReplaceRuntimeSettings {
expected_revision: gateway_admin::model::Revision::new(1).unwrap(),
request_profile_updates: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
model_mappings: Default::default(),
refresh_margin_seconds: 0,
refresh_concurrency: 1,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 0,
refresh_concurrency: 1,
max_concurrent_per_account: 1,
request_interval_ms: 0,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
},
)
.await
@@ -28,6 +28,27 @@ use super::{
WireValidationError, wire::map_admin_service_error,
};
/// 账号、代理与 Client Key 共用的分组引用
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountGroupRefView {
pub id: String,
pub name: String,
pub color: String,
pub enabled: bool,
}
impl From<gateway_admin::model::account_groups::AccountGroupRef> for AccountGroupRefView {
fn from(group: gateway_admin::model::account_groups::AccountGroupRef) -> Self {
Self {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
}
}
}
const DEFAULT_PAGE_SIZE: u32 = 50;
const MAX_PAGE_SIZE: u32 = 200;
@@ -420,16 +441,8 @@ fn group_color(value: &str) -> Result<AccountGroupColor, AdminError> {
}
fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), AdminError> {
if name.trim() != name
|| name.is_empty()
|| name.chars().count() > 100
|| name.chars().any(char::is_control)
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(AdminError::bad_request("账号组请求不合法"));
}
Ok(())
gateway_admin::model::account_groups::validate_group_fields(name, description)
.map_err(|_| AdminError::bad_request("账号组请求不合法"))
}
fn map_wire_error(_: WireValidationError) -> AdminError {
@@ -96,12 +96,7 @@ pub(super) fn account_view(
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.map(AccountGroupRefView::from)
.collect(),
resource_ref: account.id,
email: account.email,
@@ -378,14 +378,7 @@ pub struct QuotaForecastSourceView {
pub usd_display: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountGroupRefView {
pub id: String,
pub name: String,
pub color: String,
pub enabled: bool,
}
pub use crate::admin::account_groups::AccountGroupRefView;
/// Provider quota 安全视图
#[derive(Debug, Clone, Serialize)]
@@ -29,6 +29,7 @@ use axum::{
routing::{get, post},
};
use super::account_groups::AccountGroupRefView;
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
WireValidationError, wire::map_admin_service_error,
@@ -342,7 +343,7 @@ pub struct ClientKeyView {
name: String,
label: Option<String>,
routing_scope: &'static str,
groups: Vec<ClientKeyGroupView>,
groups: Vec<AccountGroupRefView>,
provider_kinds: Vec<String>,
prefix: String,
enabled: bool,
@@ -365,15 +366,6 @@ pub struct ClientKeyView {
last_used_at_full_display: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyGroupView {
id: String,
name: String,
color: String,
enabled: bool,
}
impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
fn from((record, time): (ClientKeyRecord, crate::time::TimePresenter)) -> Self {
let routing_scope = if record.groups.is_empty() {
@@ -399,12 +391,7 @@ impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
groups: record
.groups
.into_iter()
.map(|group| ClientKeyGroupView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.map(AccountGroupRefView::from)
.collect(),
provider_kinds: record
.provider_kinds
@@ -847,19 +834,13 @@ fn validate_required_text(value: &str, field: &'static str) -> Result<(), WireVa
}
fn validate_group_ids(values: Vec<String>) -> Result<Vec<AccountGroupId>, WireValidationError> {
if values.len() > 1000
|| values
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= values.len()
{
return Err(WireValidationError::new("groupIds"));
}
values
let groups = values
.into_iter()
.map(|value| AccountGroupId::new(value).map_err(|_| WireValidationError::new("groupIds")))
.collect()
.collect::<Result<Vec<_>, _>>()?;
gateway_admin::model::client_keys::validate_group_ids(&groups)
.map_err(|_| WireValidationError::new("groupIds"))?;
Ok(groups)
}
fn validate_optional_text(
@@ -4,7 +4,9 @@ use std::collections::BTreeMap;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use chrono::{DateTime, Utc};
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
use gateway_admin::model::observability::{
self as domain, ObservabilityPageSize as DomainPageSize,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -5,7 +5,7 @@ use super::*;
/// 观测列表默认页大小
pub const DEFAULT_PAGE_SIZE: u16 = 50;
/// 观测列表允许的最大页大小
pub const MAX_PAGE_SIZE: u16 = 100;
pub const MAX_PAGE_SIZE: u16 = DomainPageSize::MAX;
/// Dashboard 查询参数
#[derive(Clone, Default, Deserialize)]
@@ -59,7 +59,7 @@ impl UsageQuery {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
if DomainPageSize::new(page_size).is_err() {
return Err(WireValidationError::new("pageSize"));
}
Ok((current_page, page_size))
@@ -136,7 +136,7 @@ impl OpsQuery {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
if DomainPageSize::new(page_size).is_err() {
return Err(WireValidationError::new("pageSize"));
}
Ok((current_page, page_size))
@@ -324,12 +324,7 @@ where
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.map(AccountGroupRefView::from)
.collect(),
enabled: account.enabled,
})
+156 -250
View File
@@ -18,7 +18,6 @@ use gateway_admin::model::client_distribution::{
use gateway_admin::model::settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
};
use gateway_core::policy::CodexClientVersion;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
@@ -33,20 +32,12 @@ pub type ProviderRequestProfiles = BTreeMap<String, serde_json::Map<String, serd
pub type ProviderRequestProfileUpdates =
BTreeMap<String, Option<serde_json::Map<String, serde_json::Value>>>;
/// 运行配置投影与设置页字段的聚合响应
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
/// HTTP 读写共用的设置字段;仅保留 wire 数值类型与命名
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub config_revision: u64,
pub smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig,
pub provider_request_profiles: ProviderRequestProfiles,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub struct RuntimeSettingsFields {
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
pub max_concurrent_per_account: u64,
@@ -60,7 +51,6 @@ pub struct RuntimeSettingsView {
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
@@ -76,6 +66,23 @@ pub struct RuntimeSettingsView {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 运行配置投影与设置页字段的聚合响应
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
#[serde(flatten)]
pub values: RuntimeSettingsFields,
pub config_revision: u64,
pub smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig,
pub provider_request_profiles: ProviderRequestProfiles,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
/// 固定兼容字段;值始终从 provider_request_profiles 派生
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub model_mappings: ModelMappings,
pub rotation_strategy: String,
pub updated_at: DateTime<Utc>,
pub updated_at_display: String,
}
@@ -84,6 +91,8 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
#[serde(flatten)]
pub values: RuntimeSettingsFields,
pub config_revision: u64,
#[serde(default)]
pub provider_request_profiles: ProviderRequestProfileUpdates,
@@ -93,139 +102,22 @@ pub struct UpdateRuntimeSettingsRequest {
/// 兼容既有 wire;与泛化字段冲突时拒绝整个请求
#[serde(default, deserialize_with = "deserialize_profile_update")]
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u64,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
impl UpdateRuntimeSettingsRequest {
/// 校验公共运行参数
pub fn validate(&self) -> Result<(), WireValidationError> {
self.request_location
.validate()
.map_err(|_| WireValidationError::new("requestLocation"))?;
validate_model_mappings(&self.model_mappings)?;
if !(1..=gateway_core::account::MAX_SESSION_AFFINITY_TTL_HOURS)
.contains(&self.openai_session_affinity_ttl_hours)
{
return Err(WireValidationError::new("openaiSessionAffinityTtlHours"));
}
if self.max_account_rotations > gateway_core::account::MAX_ACCOUNT_ROTATIONS {
return Err(WireValidationError::new("maxAccountRotations"));
}
for (value, field) in [
(self.max_waiting_per_key, "maxWaitingPerKey"),
(self.max_waiting_per_account, "maxWaitingPerAccount"),
] {
if value > 1_000 {
return Err(WireValidationError::new(field));
}
}
if self.responses_max_decompressed_body_bytes == 0
|| isize::try_from(self.responses_max_decompressed_body_bytes).is_err()
{
return Err(WireValidationError::new(
"responsesMaxDecompressedBodyBytes",
));
}
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
}
for (value, field) in [
(self.refresh_margin_seconds, "refreshMarginSeconds"),
(self.refresh_concurrency, "refreshConcurrency"),
(self.usage_retention_days, "usageRetentionDays"),
(self.ops_event_retention_days, "opsEventRetentionDays"),
(self.audit_retention_days, "auditRetentionDays"),
] {
require_positive_i64(value, field)?;
}
if i64::try_from(self.request_interval_ms).is_err() {
return Err(WireValidationError::new("requestIntervalMs"));
}
if RotationStrategy::parse(&self.rotation_strategy).is_none() {
return Err(WireValidationError::new("rotationStrategy"));
}
validate_optional_client_version(
self.min_codex_desktop_version.as_deref(),
"minCodexDesktopVersion",
)?;
validate_optional_client_version(
self.min_codex_cli_version.as_deref(),
"minCodexCliVersion",
)?;
for (value, field) in [
(
self.account_auto_freeze_threshold,
"accountAutoFreezeThreshold",
),
(
self.account_auto_freeze_window_seconds,
"accountAutoFreezeWindowSeconds",
),
(
self.account_auto_freeze_duration_seconds,
"accountAutoFreezeDurationSeconds",
),
] {
require_positive_i64(value, field)?;
}
if !(2..=1_000).contains(&self.account_auto_freeze_threshold) {
return Err(WireValidationError::new("accountAutoFreezeThreshold"));
}
if !(60..=3_600).contains(&self.account_auto_freeze_window_seconds) {
return Err(WireValidationError::new("accountAutoFreezeWindowSeconds"));
}
if !(300..=604_800).contains(&self.account_auto_freeze_duration_seconds) {
return Err(WireValidationError::new("accountAutoFreezeDurationSeconds"));
}
validate_optional_probe_model(
self.account_auto_freeze_probe_model.as_deref(),
"accountAutoFreezeProbeModel",
)?;
if !gateway_core::provider_ports::valid_warmup_schedule_time(
&self.account_warmup_schedule_time,
) {
return Err(WireValidationError::new("accountWarmupScheduleTime"));
}
validate_optional_probe_model(self.account_warmup_model.as_deref(), "accountWarmupModel")?;
if self.account_warmup_enabled && self.account_warmup_model.is_none() {
return Err(WireValidationError::new("accountWarmupModel"));
}
Ok(())
self.values.clone().into_values().map(|_| ())
}
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
validate_model_mappings(&self.model_mappings)?;
let request_profile_updates = normalize_request_profile_updates(
self.provider_request_profiles,
self.openai_client_profile,
@@ -235,48 +127,10 @@ impl UpdateRuntimeSettingsRequest {
expected_revision: gateway_admin::model::Revision::new(self.config_revision)
.map_err(|_| WireValidationError::new("configRevision"))?,
request_profile_updates,
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
.normalized()
.map_err(|_| WireValidationError::new("requestLocation"))?,
model_mappings: domain_model_mappings(self.model_mappings)?,
refresh_margin_seconds: self.refresh_margin_seconds,
refresh_concurrency: u32::try_from(self.refresh_concurrency)
.map_err(|_| WireValidationError::new("settingsRefreshConcurrencyOverflow"))?,
max_concurrent_per_account: u32::try_from(self.max_concurrent_per_account)
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
request_interval_ms: self.request_interval_ms,
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
openai_account_affinity: self.openai_account_affinity,
max_account_rotations: self.max_account_rotations,
openai_session_affinity_ttl_hours: self.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
smart_scheduling: self.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
min_codex_cli_version: self.min_codex_cli_version,
usage_retention_days: u32::try_from(self.usage_retention_days)
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
.map_err(|_| WireValidationError::new("settingsOpsRetentionOverflow"))?,
audit_retention_days: u32::try_from(self.audit_retention_days)
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
account_auto_freeze_enabled: self.account_auto_freeze_enabled,
account_auto_freeze_threshold: u32::try_from(self.account_auto_freeze_threshold)
.map_err(|_| WireValidationError::new("settingsFreezeThresholdOverflow"))?,
account_auto_freeze_window_seconds: self.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: self.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: self.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: self.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: self.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_model: self.account_warmup_model,
values: self.values.into_values()?,
})
}
}
@@ -293,42 +147,12 @@ impl From<(RuntimeSettings, crate::time::TimePresenter)> for RuntimeSettingsView
openai_client_profile: provider_request_profiles.get("openai").cloned(),
xai_client_profile: provider_request_profiles.get("xai").cloned(),
provider_request_profiles,
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: wire_model_mappings(settings.model_mappings),
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: u64::from(settings.refresh_concurrency),
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
request_interval_ms: settings.request_interval_ms,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
max_account_rotations: settings.max_account_rotations,
openai_session_affinity_ttl_hours: settings.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: u64::from(settings.account_auto_freeze_threshold),
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
updated_at_display: time.datetime(&settings.updated_at),
updated_at: settings.updated_at,
values: settings.values.into(),
}
}
}
@@ -719,23 +543,13 @@ where
))
}
fn require_positive_i64(value: u64, field: &'static str) -> Result<(), WireValidationError> {
if value == 0 || i64::try_from(value).is_err() {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn validate_model_mappings(mappings: &ModelMappings) -> Result<(), WireValidationError> {
if mappings.len() > 512 {
return Err(WireValidationError::new("modelMappings"));
}
for (requested, upstream) in mappings {
if !valid_model_name(requested, 256) || !valid_model_name(upstream, 256) {
return Err(WireValidationError::new("modelMappings"));
}
}
Ok(())
gateway_admin::model::settings::validate_model_mappings(
mappings
.iter()
.map(|(requested, upstream)| (requested.as_str(), upstream.as_str())),
)
.map_err(|_| WireValidationError::new("modelMappings"))
}
fn domain_model_mappings(
@@ -761,38 +575,6 @@ fn wire_model_mappings(mappings: DomainModelMappings) -> ModelMappings {
.collect()
}
fn valid_model_name(value: &str, max_len: usize) -> bool {
!value.is_empty()
&& value.len() <= max_len
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn validate_optional_client_version(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| CodexClientVersion::parse(value).is_err()) {
return Err(WireValidationError::new(field));
}
Ok(())
}
/// 探测模型为可选自由文本:非空、去首尾空白后不变、无控制字符且不超过 128 字节
fn validate_optional_probe_model(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| {
value.is_empty()
|| value.len() > 128
|| value != value.trim()
|| value.bytes().any(|byte| byte.is_ascii_control())
}) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
@@ -931,3 +713,127 @@ fn client_profile_preview_view(
}
profile
}
impl RuntimeSettingsFields {
fn into_values(
self,
) -> Result<gateway_admin::model::settings::RuntimeSettingsValues, WireValidationError> {
let values = gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
.normalized()
.map_err(|_| WireValidationError::new("requestLocation"))?,
refresh_margin_seconds: self.refresh_margin_seconds,
refresh_concurrency: u32::try_from(self.refresh_concurrency)
.map_err(|_| WireValidationError::new("settingsRefreshConcurrencyOverflow"))?,
max_concurrent_per_account: u32::try_from(self.max_concurrent_per_account)
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
request_interval_ms: self.request_interval_ms,
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
openai_account_affinity: self.openai_account_affinity,
max_account_rotations: self.max_account_rotations,
openai_session_affinity_ttl_hours: self.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
smart_scheduling: self.smart_scheduling,
min_codex_desktop_version: self.min_codex_desktop_version,
min_codex_cli_version: self.min_codex_cli_version,
usage_retention_days: u32::try_from(self.usage_retention_days)
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
.map_err(|_| WireValidationError::new("settingsOpsRetentionOverflow"))?,
audit_retention_days: u32::try_from(self.audit_retention_days)
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
account_auto_freeze_enabled: self.account_auto_freeze_enabled,
account_auto_freeze_threshold: u32::try_from(self.account_auto_freeze_threshold)
.map_err(|_| WireValidationError::new("settingsFreezeThresholdOverflow"))?,
account_auto_freeze_window_seconds: self.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: self.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: self.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: self.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: self.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_model: self.account_warmup_model,
};
values.validate().map_err(settings_validation_error)?;
Ok(values)
}
}
impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSettingsFields {
fn from(settings: gateway_admin::model::settings::RuntimeSettingsValues) -> Self {
crate::admin::settings::RuntimeSettingsFields {
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: u64::from(settings.refresh_concurrency),
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
request_interval_ms: settings.request_interval_ms,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
max_account_rotations: settings.max_account_rotations,
openai_session_affinity_ttl_hours: settings.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: u64::from(settings.account_auto_freeze_threshold),
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
}
}
}
fn settings_validation_error(field: &'static str) -> WireValidationError {
WireValidationError::new(match field {
"account_auto_freeze_adaptive_concurrency" => "accountAutoFreezeAdaptiveConcurrency",
"account_auto_freeze_duration_seconds" => "accountAutoFreezeDurationSeconds",
"account_auto_freeze_enabled" => "accountAutoFreezeEnabled",
"account_auto_freeze_probe_enabled" => "accountAutoFreezeProbeEnabled",
"account_auto_freeze_probe_model" => "accountAutoFreezeProbeModel",
"account_auto_freeze_threshold" => "accountAutoFreezeThreshold",
"account_auto_freeze_window_seconds" => "accountAutoFreezeWindowSeconds",
"account_warmup_enabled" => "accountWarmupEnabled",
"account_warmup_model" => "accountWarmupModel",
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
"audit_retention_days" => "auditRetentionDays",
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
"max_account_rotations" => "maxAccountRotations",
"max_concurrent_per_account" => "maxConcurrentPerAccount",
"max_waiting_per_account" => "maxWaitingPerAccount",
"max_waiting_per_key" => "maxWaitingPerKey",
"min_codex_cli_version" => "minCodexCliVersion",
"min_codex_desktop_version" => "minCodexDesktopVersion",
"openai_account_affinity" => "openaiAccountAffinity",
"openai_guardian_reserved_concurrency" => "openaiGuardianReservedConcurrency",
"openai_session_affinity_ttl_hours" => "openaiSessionAffinityTtlHours",
"ops_event_retention_days" => "opsEventRetentionDays",
"refresh_concurrency" => "refreshConcurrency",
"refresh_margin_seconds" => "refreshMarginSeconds",
"request_interval_ms" => "requestIntervalMs",
"request_location" => "requestLocation",
"request_location_enabled" => "requestLocationEnabled",
"responses_max_decompressed_body_bytes" => "responsesMaxDecompressedBodyBytes",
"smart_scheduling" => "smartScheduling",
"usage_retention_days" => "usageRetentionDays",
_ => "settings",
})
}
@@ -3,8 +3,8 @@
use axum::http::StatusCode;
use chrono::Duration;
use gateway_admin::model::{
PageSize,
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
observability::ObservabilityPageSize,
};
use serde::Deserialize;
@@ -89,13 +89,14 @@ impl RecordsQuery {
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<KeyUsageRecordsQuery, AdminError> {
let current_page = self.current_page.unwrap_or(1);
let page_size = self.page_size.unwrap_or(20);
if current_page == 0 || !(1..=100).contains(&page_size) {
return Err(AdminError::invalid_request(
StatusCode::BAD_REQUEST,
"页码必须大于 0,每页数量为 1–100",
));
let invalid_page = || {
AdminError::invalid_request(StatusCode::BAD_REQUEST, "页码必须大于 0,每页数量为 1–100")
};
if current_page == 0 {
return Err(invalid_page());
}
let page_size =
ObservabilityPageSize::new(self.page_size.unwrap_or(20)).map_err(|_| invalid_page())?;
Ok(KeyUsageRecordsQuery {
usage: OverviewQuery {
period: self.period,
@@ -110,9 +111,7 @@ impl RecordsQuery {
RecordKind::Error => KeyUsageRecordKind::Error,
},
current_page,
page_size: PageSize::new(page_size).map_err(|_| {
AdminError::invalid_request(StatusCode::BAD_REQUEST, "每页数量不合法")
})?,
page_size,
})
}
}
+68 -60
View File
@@ -593,40 +593,46 @@ impl SettingsStore for MemorySettingsStore {
}
let updated = RuntimeSettings {
request_profiles,
request_location_enabled: command.request_location_enabled,
request_location: command.request_location,
config_revision: next_revision(settings.config_revision),
model_mappings: command.model_mappings,
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,
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command.openai_guardian_reserved_concurrency,
openai_account_affinity: command.openai_account_affinity,
max_account_rotations: command.max_account_rotations,
openai_session_affinity_ttl_hours: command.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
smart_scheduling: command.smart_scheduling,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: command.values.request_location_enabled,
request_location: command.values.request_location,
refresh_margin_seconds: command.values.refresh_margin_seconds,
refresh_concurrency: command.values.refresh_concurrency,
max_concurrent_per_account: command.values.max_concurrent_per_account,
request_interval_ms: command.values.request_interval_ms,
max_waiting_per_key: command.values.max_waiting_per_key,
max_waiting_per_account: command.values.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.values.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command
.values
.openai_guardian_reserved_concurrency,
openai_account_affinity: command.values.openai_account_affinity,
max_account_rotations: command.values.max_account_rotations,
openai_session_affinity_ttl_hours: command.values.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: command
.values
.responses_max_decompressed_body_bytes,
smart_scheduling: command.values.smart_scheduling,
min_codex_desktop_version: command.values.min_codex_desktop_version,
min_codex_cli_version: command.values.min_codex_cli_version,
usage_retention_days: command.values.usage_retention_days,
ops_event_retention_days: command.values.ops_event_retention_days,
audit_retention_days: command.values.audit_retention_days,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
};
*settings = updated.clone();
Ok(updated)
@@ -1605,40 +1611,42 @@ fn test_runtime_settings() -> RuntimeSettings {
]);
RuntimeSettings {
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: mappings,
refresh_margin_seconds: 3_600,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 3_600,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
}
}
@@ -206,8 +206,6 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
let settings = RuntimeSettings {
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: BTreeMap::from_iter([
(
@@ -219,39 +217,43 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
UpstreamModelId::new("grok-4.5").expect("upstream model"),
),
]),
refresh_margin_seconds: 1800,
refresh_concurrency: 4,
max_concurrent_per_account: 5,
request_interval_ms: 25,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
usage_retention_days: 32,
ops_event_retention_days: 31,
audit_retention_days: 91,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc
.with_ymd_and_hms(2026, 8, 2, 10, 30, 0)
.single()
.expect("timestamp"),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 1800,
refresh_concurrency: 4,
max_concurrent_per_account: 5,
request_interval_ms: 25,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
usage_retention_days: 32,
ops_event_retention_days: 31,
audit_retention_days: 91,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
};
let value = serde_json::to_value(RuntimeSettingsView::from((
@@ -330,8 +332,6 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.collect();
let settings = RuntimeSettings {
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: request
.model_mappings
@@ -344,37 +344,43 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
})
.collect::<Result<BTreeMap<_, _>, gateway_core::error::IdentifierError>>()
.expect("valid model mappings"),
refresh_margin_seconds: request.refresh_margin_seconds,
refresh_concurrency: u32::try_from(request.refresh_concurrency).expect("u32"),
max_concurrent_per_account: u32::try_from(request.max_concurrent_per_account).expect("u32"),
request_interval_ms: request.request_interval_ms,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: request.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
min_codex_cli_version: request.min_codex_cli_version,
usage_retention_days: u32::try_from(request.usage_retention_days).expect("u32"),
ops_event_retention_days: u32::try_from(request.ops_event_retention_days).expect("u32"),
audit_retention_days: u32::try_from(request.audit_retention_days).expect("u32"),
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: chrono::Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: request.values.refresh_margin_seconds,
refresh_concurrency: u32::try_from(request.values.refresh_concurrency).expect("u32"),
max_concurrent_per_account: u32::try_from(request.values.max_concurrent_per_account)
.expect("u32"),
request_interval_ms: request.values.request_interval_ms,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: request.values.smart_scheduling,
min_codex_desktop_version: request.values.min_codex_desktop_version,
min_codex_cli_version: request.values.min_codex_cli_version,
usage_retention_days: u32::try_from(request.values.usage_retention_days).expect("u32"),
ops_event_retention_days: u32::try_from(request.values.ops_event_retention_days)
.expect("u32"),
audit_retention_days: u32::try_from(request.values.audit_retention_days).expect("u32"),
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
};
let response_fields: BTreeSet<String> = serde_json::to_value(RuntimeSettingsView::from((
@@ -398,6 +404,48 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
assert_eq!(response_fields, expected_fields);
}
#[test]
fn settings_request_accepts_json_bytes_with_flattened_fields() {
serde_json::from_slice::<UpdateRuntimeSettingsRequest>(
&serde_json::to_vec(&update_body()).unwrap(),
)
.expect("settings JSON bytes must decode like the HTTP request body");
}
#[test]
fn settings_service_contract_stays_flat_and_rejects_unknown_fields() {
use gateway_admin::model::settings::{ReplaceRuntimeSettings, RuntimeSettings};
let current = super::test_runtime_settings();
assert_eq!(
serde_json::from_slice::<RuntimeSettings>(&serde_json::to_vec(&current).unwrap()).unwrap(),
current
);
let mut record = serde_json::to_value(&current).unwrap();
assert!(record.get("values").is_none());
assert_eq!(
serde_json::from_value::<RuntimeSettings>(record.clone()).unwrap(),
current
);
record["unexpected_setting"] = json!(true);
assert!(serde_json::from_value::<RuntimeSettings>(record).is_err());
let command = ReplaceRuntimeSettings::from(current);
assert_eq!(
serde_json::from_slice::<ReplaceRuntimeSettings>(&serde_json::to_vec(&command).unwrap())
.unwrap(),
command
);
let mut wire = serde_json::to_value(&command).unwrap();
assert!(wire.get("values").is_none());
assert_eq!(
serde_json::from_value::<ReplaceRuntimeSettings>(wire.clone()).unwrap(),
command
);
wire["unexpected_setting"] = json!(true);
assert!(serde_json::from_value::<ReplaceRuntimeSettings>(wire).is_err());
}
#[test]
fn settings_request_should_reject_unknown_revision_field() {
let mut body = update_body();
@@ -2,13 +2,24 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "SmartSchedulingValues", into = "SmartSchedulingValues")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(into = "SmartSchedulingValues")]
pub struct SmartSchedulingConfig {
weights: [u8; 6],
prefer_higher_weight: bool,
}
impl<'de> Deserialize<'de> for SmartSchedulingConfig {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// flatten 会缓冲字段,arbitrary_precision 的小数需先恢复为 JSON Number
// 随后仍由同一类型检查完整字段、数值范围和十分位精度
let value = serde_json::Value::deserialize(deserializer)?;
let values = serde_json::from_value::<SmartSchedulingValues>(value)
.map_err(serde::de::Error::custom)?;
Self::try_from(values).map_err(serde::de::Error::custom)
}
}
impl Default for SmartSchedulingConfig {
fn default() -> Self {
Self {
@@ -1111,6 +1111,38 @@ pub struct ProviderFreezePolicy {
}
impl ProviderFreezePolicy {
/// 管理写入与运行策略构造共用的冻结约束
pub fn validate_values(
threshold: u32,
window_seconds: u64,
duration_seconds: u64,
probe_model: Option<&str>,
) -> Result<(), &'static str> {
for (valid, field) in [
(
(2..=1_000).contains(&threshold),
"account_auto_freeze_threshold",
),
(
(60..=3_600).contains(&window_seconds),
"account_auto_freeze_window_seconds",
),
(
(300..=604_800).contains(&duration_seconds),
"account_auto_freeze_duration_seconds",
),
(
valid_optional_probe_model(probe_model),
"account_auto_freeze_probe_model",
),
] {
if !valid {
return Err(field);
}
}
Ok(())
}
/// 边界与迁移 `0010_account_auto_freeze.sql` 的 check 约束一致;
/// store 层写入前已校验,这里兜底防御越界配置
pub fn try_new(
@@ -1122,15 +1154,13 @@ impl ProviderFreezePolicy {
probe_model: Option<String>,
adaptive_concurrency: bool,
) -> Result<Self, ProviderStoreError> {
if !(2..=1_000).contains(&threshold)
|| !(60..=3_600).contains(&window_seconds)
|| !(300..=604_800).contains(&freeze_duration_seconds)
|| probe_model.as_deref().is_some_and(|model| {
model.is_empty()
|| model.len() > 128
|| model != model.trim()
|| model.bytes().any(|byte| byte.is_ascii_control())
})
if Self::validate_values(
threshold,
window_seconds,
freeze_duration_seconds,
probe_model.as_deref(),
)
.is_err()
{
return Err(ProviderStoreError::new(
ProviderStoreErrorKind::InvalidData,
@@ -1239,20 +1269,27 @@ pub struct ProviderWarmupPolicy {
}
impl ProviderWarmupPolicy {
/// 保存设置与创建预热策略使用相同的时间表和模型约束
pub fn validate_values(
enabled: bool,
schedule_time: &str,
model: Option<&str>,
) -> Result<(), &'static str> {
if !valid_warmup_schedule_time(schedule_time) {
return Err("account_warmup_schedule_time");
}
if (enabled && model.is_none()) || !valid_optional_probe_model(model) {
return Err("account_warmup_model");
}
Ok(())
}
pub fn try_new(
enabled: bool,
schedule_time: String,
model: Option<String>,
) -> Result<Self, ProviderStoreError> {
if !valid_warmup_schedule_time(&schedule_time)
|| (enabled && model.is_none())
|| model.as_deref().is_some_and(|m| {
m.is_empty()
|| m.len() > 128
|| m != m.trim()
|| m.bytes().any(|byte| byte.is_ascii_control())
})
{
if Self::validate_values(enabled, &schedule_time, model.as_deref()).is_err() {
return Err(ProviderStoreError::new(
ProviderStoreErrorKind::InvalidData,
"validate warmup policy",
@@ -1597,3 +1634,12 @@ impl fmt::Debug for ProviderStorePorts {
formatter.write_str("ProviderStorePorts([CAPABILITIES])")
}
}
fn valid_optional_probe_model(model: Option<&str>) -> bool {
model.is_none_or(|model| {
!model.is_empty()
&& model.len() <= 128
&& model == model.trim()
&& !model.bytes().any(|byte| byte.is_ascii_control())
})
}
@@ -6,7 +6,7 @@ use super::{InvalidSettings, SettingsValues};
use crate::{
account::{AccountConcurrency, AccountSelectionPolicy, RotationStrategy},
concurrency::ConcurrencyQueuePolicy,
policy::{CodexClientMinVersions, CodexClientVersion},
policy::CodexClientMinVersions,
};
// 持久快照和请求覆盖共用同一编译结果,避免参数校验与派生规则分叉
@@ -22,37 +22,24 @@ pub(crate) struct CompiledSettings {
impl CompiledSettings {
pub(crate) fn new(settings: SettingsValues) -> Result<Self, InvalidSettings> {
if !(1..=crate::account::MAX_SESSION_AFFINITY_TTL_HOURS)
.contains(&settings.openai_session_affinity_ttl_hours)
|| settings.max_account_rotations > crate::account::MAX_ACCOUNT_ROTATIONS
|| settings.max_waiting_per_key > 1_000
|| settings.max_waiting_per_account > 1_000
|| !(1..=120).contains(&settings.concurrency_wait_timeout_seconds)
{
return Err(InvalidSettings);
}
super::validate_request_limits(
settings.max_waiting_per_key,
settings.max_waiting_per_account,
settings.concurrency_wait_timeout_seconds,
settings.max_account_rotations,
settings.openai_session_affinity_ttl_hours,
)
.map_err(|_| InvalidSettings)?;
let queue_timeout =
Duration::from_secs(u64::from(settings.concurrency_wait_timeout_seconds));
let responses_max_decompressed_body_bytes =
isize::try_from(settings.responses_max_decompressed_body_bytes)
.ok()
.and_then(|bytes| usize::try_from(bytes).ok())
.and_then(std::num::NonZeroUsize::new)
.ok_or(InvalidSettings)?;
let min_codex_client_versions = CodexClientMinVersions::new(
settings
.min_codex_desktop_version
.as_deref()
.map(CodexClientVersion::parse)
.transpose()
.map_err(|_| InvalidSettings)?,
settings
.min_codex_cli_version
.as_deref()
.map(CodexClientVersion::parse)
.transpose()
.map_err(|_| InvalidSettings)?,
);
super::response_body_limit(settings.responses_max_decompressed_body_bytes)
.map_err(|_| InvalidSettings)?;
let min_codex_client_versions = super::client_min_versions(
settings.min_codex_desktop_version.as_deref(),
settings.min_codex_cli_version.as_deref(),
)
.map_err(|_| InvalidSettings)?;
let strategy =
RotationStrategy::parse(&settings.rotation_strategy).ok_or(InvalidSettings)?;
let account_selection_policy = AccountSelectionPolicy::new(
@@ -15,7 +15,9 @@ use crate::routing::RuntimeSnapshot;
pub(crate) mod compiled;
mod values;
pub use values::SettingsValues;
pub use values::{
SettingsValues, client_min_versions, response_body_limit, validate_request_limits,
};
/// 一次模型调用的有效设置;改写只影响当前请求,不发布配置或修改持久化 revision
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -167,3 +167,60 @@ impl SettingsValues {
self
}
}
/// 请求排队与账号选择的数值约束;返回不合法的设置字段
pub fn validate_request_limits(
max_waiting_per_key: u32,
max_waiting_per_account: u32,
timeout_seconds: u32,
max_account_rotations: u32,
affinity_ttl_hours: u32,
) -> Result<(), &'static str> {
for (valid, field) in [
(max_waiting_per_key <= 1_000, "max_waiting_per_key"),
(max_waiting_per_account <= 1_000, "max_waiting_per_account"),
(
(1..=120).contains(&timeout_seconds),
"concurrency_wait_timeout_seconds",
),
(
max_account_rotations <= crate::account::MAX_ACCOUNT_ROTATIONS,
"max_account_rotations",
),
(
(1..=crate::account::MAX_SESSION_AFFINITY_TTL_HOURS).contains(&affinity_ttl_hours),
"openai_session_affinity_ttl_hours",
),
] {
if !valid {
return Err(field);
}
}
Ok(())
}
/// 解压上限必须能作为非零缓冲区长度使用
pub fn response_body_limit(bytes: u64) -> Result<std::num::NonZeroUsize, &'static str> {
isize::try_from(bytes)
.ok()
.and_then(|bytes| usize::try_from(bytes).ok())
.and_then(std::num::NonZeroUsize::new)
.ok_or("responses_max_decompressed_body_bytes")
}
/// 管理写入与请求覆盖共用客户端最低版本解析
pub fn client_min_versions(
desktop: Option<&str>,
cli: Option<&str>,
) -> Result<crate::policy::CodexClientMinVersions, &'static str> {
use crate::policy::{CodexClientMinVersions, CodexClientVersion};
Ok(CodexClientMinVersions::new(
desktop
.map(CodexClientVersion::parse)
.transpose()
.map_err(|_| "min_codex_desktop_version")?,
cli.map(CodexClientVersion::parse)
.transpose()
.map_err(|_| "min_codex_cli_version")?,
))
}
@@ -51,7 +51,10 @@ async fn explicit_service_dispatch_composes_once_and_preserves_native_transactio
.await
.unwrap();
let visible = services.settings().load().await.unwrap();
assert_eq!(visible.request_interval_ms, original.request_interval_ms);
assert_eq!(
visible.values.request_interval_ms,
original.values.request_interval_ms
);
assert_eq!(visible.config_revision, original.config_revision);
let through_registry = services
.public_services()
@@ -70,12 +73,13 @@ async fn explicit_service_dispatch_composes_once_and_preserves_native_transactio
.load_runtime_settings()
.await
.unwrap()
.values
.request_interval_ms,
original.request_interval_ms
original.values.request_interval_ms
);
let mut command = ReplaceRuntimeSettings::from(original);
command.request_interval_ms = 999;
command.values.request_interval_ms = 999;
let saved = services
.public_services()
.call(
@@ -86,7 +90,7 @@ async fn explicit_service_dispatch_composes_once_and_preserves_native_transactio
.unwrap();
let saved: gateway_admin::model::settings::RuntimeSettings =
serde_json::from_value(saved).unwrap();
assert_eq!(saved.request_interval_ms, 1234);
assert_eq!(saved.values.request_interval_ms, 1234);
assert!(saved.config_revision > command.expected_revision);
let current = environment
.store
@@ -95,7 +99,7 @@ async fn explicit_service_dispatch_composes_once_and_preserves_native_transactio
.load_runtime_settings()
.await
.unwrap();
assert_eq!(current.request_interval_ms, 1234);
assert_eq!(current.values.request_interval_ms, 1234);
assert_eq!(
core.snapshots()
.snapshot_for_diagnostics()
@@ -328,6 +332,7 @@ async fn cli_service_callback_uses_the_same_public_settings_and_plugin_chain() {
.load_runtime_settings()
.await
.unwrap()
.values
.request_interval_ms,
4321
);
@@ -18,11 +18,8 @@ pub type PricingOverrides = BTreeMap<String, BTreeMap<String, ModelPriceOverride
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
@@ -36,7 +33,6 @@ pub struct RuntimeSettings {
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
@@ -52,18 +48,17 @@ pub struct RuntimeSettings {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub model_mappings: ModelMappings,
pub rotation_strategy: RotationStrategy,
pub updated_at: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceRuntimeSettings {
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值
pub request_profile_updates: ProviderRequestProfileUpdates,
pub request_location_enabled: bool,
pub request_location: RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
@@ -77,7 +72,6 @@ pub struct ReplaceRuntimeSettings {
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
@@ -93,6 +87,12 @@ pub struct ReplaceRuntimeSettings {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值
pub request_profile_updates: ProviderRequestProfileUpdates,
pub model_mappings: ModelMappings,
pub rotation_strategy: RotationStrategy,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -253,15 +253,8 @@ impl Operation for PreviewClientProfile {
impl From<RuntimeSettings> for ReplaceRuntimeSettings {
fn from(settings: RuntimeSettings) -> Self {
Self {
expected_revision: settings.config_revision,
request_profile_updates: settings
.request_profiles
.into_iter()
.map(|(provider, value)| (provider, Some(value)))
.collect(),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: settings.model_mappings,
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: settings.refresh_concurrency,
max_concurrent_per_account: settings.max_concurrent_per_account,
@@ -275,7 +268,6 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
openai_session_affinity_ttl_hours: settings.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
@@ -292,6 +284,14 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
expected_revision: settings.config_revision,
request_profile_updates: settings
.request_profiles
.into_iter()
.map(|(provider, value)| (provider, Some(value)))
.collect(),
model_mappings: settings.model_mappings,
rotation_strategy: settings.rotation_strategy,
}
}
}
@@ -84,40 +84,9 @@ impl SettingsStore for AdminSettingsStoreAdapter {
expected_revision: store_revision(command.expected_revision)?,
settings: postgres::RuntimeSettingsUpdate {
request_profile_updates: command.request_profile_updates,
refresh_margin_seconds: command.refresh_margin_seconds,
refresh_concurrency: command.refresh_concurrency,
max_concurrent_per_account: command.max_concurrent_per_account,
request_location_enabled: command.request_location_enabled,
request_location: command.request_location,
request_interval_ms: command.request_interval_ms,
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command.openai_guardian_reserved_concurrency,
openai_account_affinity: command.openai_account_affinity,
max_account_rotations: command.max_account_rotations,
openai_session_affinity_ttl_hours: command.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: command
.responses_max_decompressed_body_bytes,
smart_scheduling: command.smart_scheduling,
rotation_strategy: command.rotation_strategy.as_str().to_owned(),
model_mappings: store_model_mappings(command.model_mappings),
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
account_auto_freeze_enabled: command.account_auto_freeze_enabled,
account_auto_freeze_threshold: command.account_auto_freeze_threshold,
account_auto_freeze_window_seconds: command.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: command.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: command.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: command.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: command
.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: command.account_warmup_enabled,
account_warmup_schedule_time: command.account_warmup_schedule_time,
account_warmup_model: command.account_warmup_model,
values: command.values,
},
audit: mutation_audit(
context,
@@ -236,39 +205,10 @@ pub(crate) fn admin_runtime_settings(
Ok(AdminRuntimeSettings {
request_profiles: settings.request_profiles,
config_revision: admin_revision(settings.config_revision)?,
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
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,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
max_account_rotations: settings.max_account_rotations,
openai_session_affinity_ttl_hours: settings.openai_session_affinity_ttl_hours,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
updated_at: settings.updated_at,
values: settings.values,
})
}
+1 -1
View File
@@ -6,7 +6,7 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use std::{fmt, num::NonZeroU64, str::FromStr};
use std::{fmt, num::NonZeroU64};
use gateway_admin::model::auth::{AdminAuditEvent as AdminAuditModel, AuthSession, SessionSubject};
use gateway_admin::model::settings::{
@@ -628,16 +628,8 @@ fn validate_page_query(query: &AccountGroupListQuery) -> AdminStoreResult<()> {
}
fn validate_group_fields(name: &str, description: Option<&str>) -> AdminStoreResult<()> {
if name.trim() != name
|| name.is_empty()
|| name.chars().count() > 100
|| name.chars().any(char::is_control)
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(invalid_admin("invalid account group fields"));
}
Ok(())
gateway_admin::model::account_groups::validate_group_fields(name, description)
.map_err(|_| invalid_admin("invalid account group fields"))
}
fn require_one(rows: u64, id: &str) -> StoreResult<()> {
@@ -1340,15 +1340,13 @@ async fn replace_client_api_key_groups_in_transaction(
}
fn validate_group_ids(group_ids: &[String]) -> StoreResult<()> {
if group_ids.len() > 1000
|| group_ids.iter().collect::<BTreeSet<_>>().len() != group_ids.len()
|| group_ids
.iter()
.any(|id| AccountGroupId::new(id.clone()).is_err())
{
return Err(invalid("group IDs are invalid or duplicated"));
}
Ok(())
let groups = group_ids
.iter()
.map(|id| AccountGroupId::new(id.clone()))
.collect::<Result<Vec<_>, _>>()
.map_err(|_| invalid("group IDs are invalid or duplicated"))?;
gateway_admin::model::client_keys::validate_group_ids(&groups)
.map_err(|_| invalid("group IDs are invalid or duplicated"))
}
fn validate_key(key: &str) -> StoreResult<()> {
@@ -16,7 +16,7 @@ use gateway_core::engine::{
use gateway_core::error::{
ProviderErrorKind, StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind,
};
use gateway_core::metering::{CostSource as CoreCostSource, Usage as CoreUsage};
use gateway_core::metering::CostSource as CoreCostSource;
use gateway_core::routing::{AccountRoutingScopeKind, AccountRoutingSnapshot};
use gateway_core::upstream::UpstreamSendState as CoreUpstreamSendState;
@@ -222,50 +222,25 @@ impl ModelRequestAttemptStart {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelRequestUsage {
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 use gateway_core::{engine::ModelRequestTimings, metering::Usage as ModelRequestUsage};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelRequestTimings {
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>,
}
impl ModelRequestTimings {
fn validate(&self) -> StoreResult<()> {
if let Some(total) = self.latency_ms {
let phases = [
self.transport_decision_wait_ms,
self.connect_ms,
self.headers_ms,
self.first_event_ms,
self.first_reasoning_ms,
self.first_text_ms,
self.first_token_ms,
self.provider_processing_ms,
];
if phases.into_iter().flatten().any(|phase| phase > total) {
return Err(invalid("timing phase exceeds total latency"));
}
fn validate_timings(timings: &ModelRequestTimings) -> StoreResult<()> {
if let Some(total) = timings.latency_ms {
let phases = [
timings.transport_decision_wait_ms,
timings.connect_ms,
timings.headers_ms,
timings.first_event_ms,
timings.first_reasoning_ms,
timings.first_text_ms,
timings.first_token_ms,
timings.provider_processing_ms,
];
if phases.into_iter().flatten().any(|phase| phase > total) {
return Err(invalid("timing phase exceeds total latency"));
}
Ok(())
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -378,7 +353,7 @@ impl ModelRequestFinalization {
"continuation unavailable reason",
)?;
validate_connection_observation(self)?;
self.timings.validate()
validate_timings(&self.timings)
}
}
@@ -1280,8 +1255,10 @@ impl ExecutionStore for PgExecutionStore {
total
.amount()
.to_string()
.parse()
.map_err(core_store_error)?,
.parse::<DecimalAmount>()
.map_err(|source| {
CoreStoreError::caused_by(CoreStoreErrorKind::InvalidData, source)
})?,
),
Some(total.currency().as_str().to_owned()),
),
@@ -1348,22 +1325,12 @@ impl ExecutionStore for PgExecutionStore {
upstream_connection_age_ms,
upstream_connection_idle_ms,
retry_after_ms: finalization.retry_after_ms,
usage: usage_from_core(finalization.usage),
usage: finalization.usage,
image_generation_succeeded: finalization.image_generation_succeeded,
cost_source,
cost_amount,
cost_currency,
timings: ModelRequestTimings {
transport_decision_wait_ms: finalization.timings.transport_decision_wait_ms,
connect_ms: finalization.timings.connect_ms,
headers_ms: finalization.timings.headers_ms,
first_event_ms: finalization.timings.first_event_ms,
first_reasoning_ms: finalization.timings.first_reasoning_ms,
first_text_ms: finalization.timings.first_text_ms,
first_token_ms: finalization.timings.first_token_ms,
provider_processing_ms: finalization.timings.provider_processing_ms,
latency_ms: finalization.timings.latency_ms,
},
timings: finalization.timings,
completed_at: DateTime::<Utc>::from(finalization.completed_at),
},
)
@@ -1386,19 +1353,6 @@ impl ExecutionStore for PgExecutionStore {
}
}
fn usage_from_core(usage: CoreUsage) -> ModelRequestUsage {
ModelRequestUsage {
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,
total_tokens: usage.total_tokens,
}
}
const fn send_state_from_core(value: CoreUpstreamSendState) -> UpstreamSendState {
match value {
CoreUpstreamSendState::NotSent => UpstreamSendState::NotSent,
@@ -137,7 +137,8 @@ impl ObservabilityRepository for PgObservabilityRepository {
..UsageRecordFilter::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10)?,
page_size: ObservabilityPageSize::new(10)
.map_err(|_| invalid("invalid dashboard page size"))?,
};
// 每条 SQL 独立取一个全局观测槽位,避免整包预留造成队头阻塞
let (totals, (provider_accounts, _)) = futures::try_join!(
@@ -307,7 +308,7 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
.dashboard_summary(store_range(range)?, observed_at)
.await
.map_err(observability_error)?;
admin_dashboard_observation(observation)
Ok(admin_dashboard_observation(observation))
}
async fn dashboard_runtime_slots(
@@ -378,13 +379,14 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
&self,
range: admin_observability::TimeRange,
) -> AdminStoreResult<Vec<admin_observability::RequestMetricPoint>> {
self.repository
Ok(self
.repository
.dashboard_trend(store_range(range)?)
.await
.map_err(observability_error)?
.into_iter()
.map(admin_request_metric_point)
.collect()
.collect())
}
async fn usage_trend(
@@ -392,13 +394,14 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
range: admin_observability::TimeRange,
filter: admin_observability::UsageFilter,
) -> AdminStoreResult<Vec<admin_observability::RequestMetricPoint>> {
self.repository
Ok(self
.repository
.usage_trend(store_range(range)?, store_usage_filter(filter))
.await
.map_err(observability_error)?
.into_iter()
.map(admin_request_metric_point)
.collect()
.collect())
}
fn usage_calculated_billing_facts(
@@ -413,7 +416,7 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
self.repository
.usage_calculated_billing_facts(range, store_usage_filter(filter))
.map_err(observability_error)
.map(|fact| fact.and_then(admin_calculated_usage_billing_fact))
.map_ok(admin_calculated_usage_billing_fact)
.boxed()
}
@@ -426,7 +429,7 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
.list_usage_records(store_usage_query(query)?)
.await
.map_err(observability_error)?;
admin_usage_page(page)
Ok(page)
}
async fn usage_record_detail(
@@ -438,7 +441,7 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
.usage_record_detail(request_id)
.await
.map_err(observability_error)?;
admin_usage_detail(detail)
Ok(detail)
}
async fn usage_summary(
@@ -451,7 +454,7 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
.usage_summary(store_range(range)?, store_usage_filter(filter))
.await
.map_err(observability_error)?;
admin_usage_overview(overview)
Ok(admin_usage_overview(overview))
}
async fn usage_diagnostics(
@@ -469,7 +472,7 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
)
.await
.map_err(observability_error)?;
admin_diagnostics_observation(observation)
Ok(admin_diagnostics_observation(observation))
}
async fn list_ops_errors(
@@ -481,7 +484,7 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
.list_ops_errors(store_ops_error_query(query)?)
.await
.map_err(observability_error)?;
admin_ops_error_page(page)
Ok(page)
}
}
@@ -44,39 +44,18 @@ pub(crate) fn store_usage_query(
range: store_range(query.range)?,
filter: store_usage_filter(query.filter),
current_page: query.current_page,
page_size: ObservabilityPageSize::new(query.page_size.get())
.map_err(observability_error)?,
page_size: query.page_size,
})
}
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,
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),
filter: query.filter,
current_page: query.current_page,
page_size: ObservabilityPageSize::new(query.page_size.get())
.map_err(observability_error)?,
page_size: query.page_size,
})
}
@@ -96,7 +75,7 @@ pub(crate) const fn store_diagnostic_dimension(
pub(crate) fn admin_dashboard_observation(
observation: DashboardObservation,
) -> AdminStoreResult<admin_observability::DashboardObservation> {
) -> admin_observability::DashboardObservation {
let DashboardObservation {
range,
totals,
@@ -105,35 +84,17 @@ pub(crate) fn admin_dashboard_observation(
account_usage,
recent_requests,
} = observation;
Ok(admin_observability::DashboardObservation {
admin_observability::DashboardObservation {
range: admin_range(range),
totals: admin_dashboard_totals(totals)?,
provider_accounts: admin_account_pool_metrics(provider_accounts),
trend: trend
.into_iter()
.map(admin_request_metric_point)
.collect::<AdminStoreResult<_>>()?,
totals,
provider_accounts,
trend: trend.into_iter().map(admin_request_metric_point).collect(),
account_usage: account_usage
.into_iter()
.map(admin_dashboard_account_usage)
.collect::<AdminStoreResult<_>>()?,
recent_requests: recent_requests
.into_iter()
.map(admin_usage_list_record)
.collect::<AdminStoreResult<_>>()?,
})
}
pub(crate) fn admin_dashboard_totals(
totals: DashboardTotals,
) -> AdminStoreResult<admin_observability::DashboardTotals> {
Ok(admin_observability::DashboardTotals {
request_count: totals.request_count,
input_tokens: totals.input_tokens,
cached_tokens: totals.cached_tokens,
total_tokens: totals.total_tokens,
billing_usd: admin_optional_decimal_amount(totals.billing_usd)?,
})
.collect(),
recent_requests,
}
}
pub(crate) const fn admin_range(range: ObservabilityRange) -> admin_observability::TimeRange {
@@ -145,8 +106,8 @@ pub(crate) const fn admin_range(range: ObservabilityRange) -> admin_observabilit
pub(crate) fn admin_request_metrics(
metrics: RequestMetrics,
) -> AdminStoreResult<admin_observability::RequestMetrics> {
Ok(admin_observability::RequestMetrics {
) -> admin_observability::RequestMetrics {
admin_observability::RequestMetrics {
request_count: metrics.request_count,
success_count: metrics.success_count,
failure_count: metrics.failure_count,
@@ -165,18 +126,12 @@ pub(crate) fn admin_request_metrics(
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,
)?,
latency_percentiles: metrics.latency_percentiles,
first_token_latency_percentiles: metrics.first_token_latency_percentiles,
admission_decision_count: metrics.admission_decision_count,
admission_decision_percentiles: admin_latency_percentiles(
metrics.admission_decision_percentiles,
)?,
admission_decision_percentiles: metrics.admission_decision_percentiles,
account_selection_wait_count: metrics.account_selection_wait_count,
account_selection_wait_percentiles: admin_latency_percentiles(
metrics.account_selection_wait_percentiles,
)?,
account_selection_wait_percentiles: metrics.account_selection_wait_percentiles,
output_throughput_p10: metrics.output_throughput_p10,
output_throughput_p50: metrics.output_throughput_p50,
output_throughput_p90: metrics.output_throughput_p90,
@@ -185,43 +140,13 @@ pub(crate) fn admin_request_metrics(
capacity_utilization_p95_basis_points: metrics.capacity_utilization_p95_basis_points,
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 {
source: None,
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 {
) -> admin_observability::AttemptMetrics {
admin_observability::AttemptMetrics {
attempt_count: metrics.attempt_count,
success_count: metrics.success_count,
failure_count: metrics.failure_count,
@@ -231,8 +156,8 @@ pub(crate) fn admin_attempt_metrics(
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)?,
})
costs: metrics.costs,
}
}
pub(crate) const fn admin_cost_coverage(
@@ -247,55 +172,10 @@ pub(crate) const fn admin_cost_coverage(
}
}
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 {
source: None,
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,
normal: metrics.normal,
quota_exhausted: metrics.quota_exhausted,
rate_limited: metrics.rate_limited,
disabled: metrics.disabled,
error: metrics.error,
}
}
pub(crate) fn admin_dashboard_account_usage(
usage: ProviderAccountUsageObservation,
) -> AdminStoreResult<admin_observability::DashboardAccountUsage> {
Ok(admin_observability::DashboardAccountUsage {
) -> admin_observability::DashboardAccountUsage {
admin_observability::DashboardAccountUsage {
account_id: usage.account_id,
provider_kind: usage.provider_kind,
authentication_kind: usage.authentication_kind,
@@ -316,32 +196,23 @@ pub(crate) fn admin_dashboard_account_usage(
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)?,
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(),
request_buckets: usage.request_buckets,
quota_used_percent: None,
quota_window: None,
models: usage
.models
.into_iter()
.map(admin_dashboard_account_model_usage)
.collect::<AdminStoreResult<_>>()?,
})
.map(admin_account_model_usage)
.collect(),
}
}
pub(crate) fn admin_dashboard_account_model_usage(
pub(crate) fn admin_account_model_usage(
usage: ProviderAccountModelUsageObservation,
) -> AdminStoreResult<admin_observability::DashboardAccountModelUsage> {
Ok(admin_observability::DashboardAccountModelUsage {
) -> admin_observability::AccountModelUsage {
admin_observability::AccountModelUsage {
model: usage.model,
request_count: usage.request_count,
success_count: usage.success_count,
@@ -356,27 +227,27 @@ pub(crate) fn admin_dashboard_account_model_usage(
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)?,
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 {
) -> admin_observability::RequestMetricPoint {
admin_observability::RequestMetricPoint {
bucket_start: point.bucket_start,
granularity: admin_granularity(point.granularity),
metrics: admin_request_metrics(point.metrics)?,
metrics: admin_request_metrics(point.metrics),
cost_coverage: admin_cost_coverage(point.cost_coverage),
costs: admin_currency_costs(point.costs)?,
})
costs: point.costs,
}
}
pub(crate) fn admin_calculated_usage_billing_fact(
fact: CalculatedUsageBillingFact,
) -> AdminStoreResult<admin_observability::UsageCalculatedBillingFact> {
Ok(admin_observability::UsageCalculatedBillingFact {
) -> admin_observability::UsageCalculatedBillingFact {
admin_observability::UsageCalculatedBillingFact {
breakdown: fact
.billing_snapshot_json
.as_ref()
@@ -389,11 +260,8 @@ pub(crate) fn admin_calculated_usage_billing_fact(
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)?,
},
})
total: fact.total,
}
}
pub(crate) const fn admin_granularity(
@@ -406,21 +274,6 @@ pub(crate) const fn admin_granularity(
}
}
pub(crate) fn admin_usage_page(
page: UsageRecordPage,
) -> AdminStoreResult<admin_observability::UsagePage> {
Ok(admin_observability::UsagePage {
items: page
.items
.into_iter()
.map(admin_usage_list_record)
.collect::<AdminStoreResult<_>>()?,
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
})
}
fn restore_billing_snapshot(
billing: Option<admin_observability::UsageBilling>,
snapshot: Option<&serde_json::Value>,
@@ -437,257 +290,27 @@ fn restore_billing_snapshot(
billing
}
pub(crate) fn admin_usage_list_record(
record: UsageListRecord,
) -> AdminStoreResult<admin_observability::UsageListRecord> {
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 {
source: None,
entity: "observability request billing",
message: "cost amount and currency must be present together".to_owned(),
}));
}
};
let billing = restore_billing_snapshot(billing, record.billing_snapshot_json.as_ref());
Ok(admin_observability::UsageListRecord {
client_api_key_name: record.client_api_key_name,
id: record.id,
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_notes: record.provider_account_notes,
provider_account_plan_type: record.provider_account_plan_type,
provider_account_plan_type_display: None,
provider_account_authentication_kind: record.provider_account_authentication_kind,
upstream_model_id: record.upstream_model_id,
upstream_transport: record.upstream_transport,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
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,
admission_decision_ms: record.admission_decision_ms,
account_selection_wait_ms: record.account_selection_wait_ms,
capacity_used_slots: record.capacity_used_slots,
capacity_total_slots: record.capacity_total_slots,
client_ip: record.client_ip,
user_agent: record.user_agent,
reasoning_effort: record.reasoning_effort,
reasoning_preset: record.reasoning_preset,
subagent_kind: record.subagent_kind,
compact: record.compact,
started_at: record.started_at,
})
}
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 {
source: None,
entity: "observability request billing",
message: "cost amount and currency must be present together".to_owned(),
}));
}
};
let billing = restore_billing_snapshot(billing, record.billing_snapshot_json.as_ref());
Ok(admin_observability::UsageRecord {
id: record.id,
client_api_key_ref: record.client_api_key_ref,
config_revision: record.config_revision,
routing_scope: record.routing_scope,
routing_group_refs: record.routing_group_refs,
routing_group_names_snapshot: record.routing_group_names_snapshot,
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,
upstream_response_model: record.upstream_response_model,
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,
admission_decision_ms: record.admission_decision_ms,
account_selection_wait_ms: record.account_selection_wait_ms,
capacity_used_slots: record.capacity_used_slots,
capacity_total_slots: record.capacity_total_slots,
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> {
pub(crate) fn request_outcome(outcome: &str) -> StoreResult<admin_observability::RequestOutcome> {
admin_observability::RequestOutcome::new(outcome.to_owned()).map_err(|_| {
observability_error(StoreError::InvalidData {
StoreError::InvalidData {
source: None,
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 {
trace: detail.trace,
related_requests: detail.related_requests,
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 {
pub(crate) fn admin_usage_overview(overview: UsageOverview) -> admin_observability::UsageOverview {
admin_observability::UsageOverview {
range: admin_range(overview.range),
requests: admin_request_metrics(overview.requests)?,
attempts: admin_attempt_metrics(overview.attempts)?,
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(
@@ -704,21 +327,21 @@ pub(crate) fn admin_provider_observation(
pub(crate) fn admin_diagnostics_observation(
observation: DiagnosticsObservation,
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
Ok(admin_observability::DiagnosticsObservation {
) -> admin_observability::DiagnosticsObservation {
admin_observability::DiagnosticsObservation {
total_request_count: observation.total_request_count,
items: observation
.items
.into_iter()
.map(admin_diagnostic_observation)
.collect::<AdminStoreResult<_>>()?,
})
.collect(),
}
}
fn admin_diagnostic_observation(
observation: DiagnosticObservation,
) -> AdminStoreResult<admin_observability::DiagnosticObservation> {
Ok(admin_observability::DiagnosticObservation {
) -> admin_observability::DiagnosticObservation {
admin_observability::DiagnosticObservation {
key: observation.key,
name: observation.name,
account_provider_kind: observation.account_provider_kind,
@@ -735,85 +358,25 @@ fn admin_diagnostic_observation(
retry_count: observation.retry_count,
retried_request_count: observation.retried_request_count,
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(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
})
}
pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
admin_observability::OpsError {
client_api_key_name: error.client_api_key_name,
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,
protocol: error.protocol,
client_transport: error.client_transport,
requested_model_id: error.requested_model_id,
service_tier: error.service_tier,
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_plan_type: error.provider_account_plan_type,
provider_account_plan_type_display: None,
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,
upstream_send_state: error.upstream_send_state,
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,
error_details: error.error_details,
client_ip: error.client_ip,
user_agent: error.user_agent,
reasoning_effort: error.reasoning_effort,
reasoning_preset: error.reasoning_preset,
request_kind: error.request_kind,
subagent_kind: error.subagent_kind,
compact: error.compact,
continuation_affinity_hash: error.continuation_affinity_hash,
continuation_previous_response_id_hash: error.continuation_previous_response_id_hash,
continuation_unavailable_reason: error.continuation_unavailable_reason,
upstream_connection_id: error.upstream_connection_id,
upstream_connection_exit_reason: error.upstream_connection_exit_reason,
upstream_connection_age_ms: error.upstream_connection_age_ms,
upstream_connection_idle_ms: error.upstream_connection_idle_ms,
recovery_request_id: error.recovery_request_id,
recovered_at: error.recovered_at,
recovery_attempt_count: error.recovery_attempt_count,
recovery_retry_delay_ms: error.recovery_retry_delay_ms,
recovery_total_latency_ms: error.recovery_total_latency_ms,
occurred_at: error.occurred_at,
stable_sort_id: error.stable_sort_id,
costs: observation.costs,
}
}
pub(crate) fn usage_list_record_from_row(
row: &sqlx::postgres::PgRow,
) -> StoreResult<UsageListRecord> {
let cost_source: String = get(row, "cost_source")?;
let cost_amount = optional_decimal(row, "cost_amount")?;
let cost_currency: Option<String> = get(row, "cost_currency")?;
let billing = billing_from_row(
row,
&cost_source,
cost_amount.as_ref(),
cost_currency.as_deref(),
)?;
Ok(UsageListRecord {
client_api_key_name: get(row, "client_api_key_name")?,
billing_snapshot_json: get(row, "billing_snapshot_json")?,
billing,
id: get(row, "id")?,
endpoint: get(row, "endpoint")?,
client_transport: get(row, "client_transport")?,
@@ -824,6 +387,7 @@ pub(crate) fn usage_list_record_from_row(
provider_account_email: get(row, "provider_account_email")?,
provider_account_notes: get(row, "provider_account_notes")?,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_plan_type_display: None,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -837,9 +401,9 @@ pub(crate) fn usage_list_record_from_row(
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")?,
cost_source,
cost_amount,
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")?,
@@ -864,8 +428,17 @@ pub(crate) fn usage_list_record_from_row(
}
pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<UsageRecord> {
let cost_source: String = get(row, "cost_source")?;
let cost_amount = optional_decimal(row, "cost_amount")?;
let cost_currency: Option<String> = get(row, "cost_currency")?;
let billing = billing_from_row(
row,
&cost_source,
cost_amount.as_ref(),
cost_currency.as_deref(),
)?;
Ok(UsageRecord {
billing_snapshot_json: get(row, "billing_snapshot_json")?,
billing,
id: get(row, "id")?,
client_api_key_ref: get(row, "client_api_key_ref")?,
config_revision: unsigned(row, "config_revision")?,
@@ -899,7 +472,7 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
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")?,
outcome: request_outcome(&get::<String>(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")?,
@@ -917,9 +490,9 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
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")?,
cost_source,
cost_amount,
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")?,
@@ -970,6 +543,7 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_plan_type_display: None,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -1010,7 +584,7 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
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")?)?,
amount: parse_decimal_amount(&get::<String>(row, "amount")?)?,
})
}
@@ -1036,7 +610,7 @@ pub(crate) fn optional_decimal(
column: &'static str,
) -> StoreResult<Option<DecimalAmount>> {
get::<Option<String>>(row, column)?
.map(|value| DecimalAmount::from_str(&value))
.map(|value| parse_decimal_amount(&value))
.transpose()
}
@@ -1126,3 +700,38 @@ pub(crate) fn invalid(message: &str) -> StoreError {
message: message.to_owned(),
}
}
fn parse_decimal_amount(value: &str) -> StoreResult<DecimalAmount> {
DecimalAmount::from_str(value).map_err(|source| StoreError::InvalidData {
source: Some(source.into()),
entity: "decimal amount",
message: "expected a non-negative numeric(20,10) value".to_owned(),
})
}
fn billing_from_row(
row: &sqlx::postgres::PgRow,
source: &str,
amount: Option<&DecimalAmount>,
currency: Option<&str>,
) -> StoreResult<Option<admin_observability::UsageBilling>> {
let billing = match (amount, currency) {
(Some(amount), Some(currency)) => Some(admin_observability::UsageBilling::Total {
source: source.to_owned(),
total: admin_observability::CurrencyCost {
currency: currency.to_owned(),
amount: amount.clone(),
},
}),
(None, None) => None,
_ => {
return Err(StoreError::InvalidData {
source: None,
entity: "observability request billing",
message: "cost amount and currency must be present together".to_owned(),
});
}
};
let snapshot: Option<serde_json::Value> = get(row, "billing_snapshot_json")?;
Ok(restore_billing_snapshot(billing, snapshot.as_ref()))
}
@@ -3,7 +3,6 @@
use super::*;
use futures::stream::BoxStream;
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;
@@ -27,22 +26,7 @@ impl ObservabilityRange {
}
}
#[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
}
}
pub use gateway_admin::model::observability::ObservabilityPageSize;
pub(crate) fn observability_page_offset(
current_page: u32,
@@ -120,57 +104,44 @@ pub struct UsageRecordQuery {
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 status_code: Option<u16>,
pub search: Option<String>,
}
pub use gateway_admin::model::observability::OpsErrorFilter;
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",
),
] {
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(())
pub(crate) fn validate_ops_error_filter(filter: &OpsErrorFilter) -> StoreResult<()> {
for (value, field) in [
(
filter.client_api_key_ref.as_deref(),
"client API key filter",
),
(filter.request_id.as_deref(), "request ID filter"),
(
filter.provider_account_ref.as_deref(),
"provider account filter",
),
(filter.provider_kind.as_deref(), "provider filter"),
(filter.operation.as_deref(), "operation filter"),
(filter.model.as_deref(), "model filter"),
(filter.transport.as_deref(), "transport filter"),
(
filter.upstream_request_id.as_deref(),
"upstream request ID filter",
),
] {
validate_optional_text(value, MAX_FILTER_BYTES, field)?;
}
validate_optional_text(filter.search.as_deref(), MAX_SEARCH_BYTES, "search filter")?;
if filter
.status_code
.is_some_and(|status| !(100..=599).contains(&status))
{
return Err(invalid("status code filter must be between 100 and 599"));
}
if filter
.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)]
@@ -192,11 +163,7 @@ pub enum DiagnosticDimension {
Status,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurrencyCostTotal {
pub currency: String,
pub amount: DecimalAmount,
}
pub use gateway_admin::model::observability::CurrencyCost as CurrencyCostTotal;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CostCoverage {
@@ -252,40 +219,7 @@ impl RequestMetrics {
}
}
/// 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(crate::StoreError::Unavailable {
backend: crate::StoreBackend::PostgreSql,
message: "decode latency percentile".to_owned(),
source: None,
});
}
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>,
}
pub use gateway_admin::model::observability::{LatencyPercentiles, PercentileMilliseconds};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttemptMetrics {
@@ -351,21 +285,9 @@ pub struct CalculatedUsageBillingFact {
pub total: CurrencyCostTotal,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderAccountMetrics {
pub total: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
pub use gateway_admin::model::observability::AccountPoolMetrics as ProviderAccountMetrics;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountRequestBucket {
pub bucket_start: DateTime<Utc>,
pub request_count: u64,
}
pub use gateway_admin::model::observability::AccountRequestBucket as ProviderAccountRequestBucket;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountUsageObservation {
@@ -467,14 +389,7 @@ impl ProviderAccountUsageQuery {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DashboardTotals {
pub request_count: u64,
pub input_tokens: u64,
pub cached_tokens: u64,
pub total_tokens: u64,
pub billing_usd: Option<DecimalAmount>,
}
pub use gateway_admin::model::observability::DashboardTotals;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardObservation {
@@ -487,186 +402,15 @@ pub struct DashboardObservation {
}
/// 使用记录列表所需的窄投影;完整执行、路由和客户端详情按 ID 单独读取
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub billing_snapshot_json: Option<serde_json::Value>,
pub id: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: 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_notes: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub upstream_response_model: Option<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 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 admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub started_at: DateTime<Utc>,
}
pub use gateway_admin::model::observability::UsageListRecord;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecord {
pub billing_snapshot_json: Option<serde_json::Value>,
pub id: String,
pub client_api_key_ref: String,
pub config_revision: u64,
pub routing_scope: String,
pub routing_group_refs: Vec<String>,
pub routing_group_names_snapshot: Vec<String>,
pub protocol: String,
pub operation: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: 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 http_version: Option<String>,
pub websocket_pool: Option<String>,
pub upstream_response_model: 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 admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: 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>>,
}
pub use gateway_admin::model::observability::UsageRecord;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecordPage {
pub items: Vec<UsageListRecord>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
pub use gateway_admin::model::observability::UsagePage as UsageRecordPage;
#[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>,
}
pub use gateway_admin::model::observability::UsageAttempt as UsageAttemptObservation;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecordDetail {
pub trace: Option<serde_json::Value>,
pub related_requests: Vec<serde_json::Value>,
pub request: UsageRecord,
pub attempts: Vec<UsageAttemptObservation>,
}
pub use gateway_admin::model::observability::UsageDetail as UsageRecordDetail;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderObservation {
@@ -712,69 +456,9 @@ pub struct DiagnosticObservation {
pub costs: Vec<CurrencyCostTotal>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorRecord {
pub client_api_key_name: Option<String>,
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 protocol: Option<String>,
pub client_transport: Option<String>,
pub requested_model_id: Option<String>,
pub service_tier: Option<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_plan_type: 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 upstream_send_state: Option<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 error_details: Option<String>,
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: Option<bool>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
pub use gateway_admin::model::observability::OpsError as OpsErrorRecord;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorPage {
pub items: Vec<OpsErrorRecord>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
pub use gateway_admin::model::observability::OpsErrorPage;
#[async_trait]
pub trait ObservabilityRepository: Send + Sync {
@@ -616,7 +616,10 @@ pub(crate) fn optional_percentile(
) -> StoreResult<Option<PercentileMilliseconds>> {
row.try_get::<Option<f64>, _>(column)
.map_err(|source| postgres_unavailable("decode latency percentile", source))?
.map(PercentileMilliseconds::new)
.map(|value| {
PercentileMilliseconds::new(value)
.map_err(|source| postgres_unavailable("decode latency percentile", source))
})
.transpose()
}
@@ -76,7 +76,7 @@ pub(crate) async fn list_ops_errors(
pool: &PgPool,
query: OpsErrorQuery,
) -> StoreResult<OpsErrorPage> {
query.filter.validate()?;
validate_ops_error_filter(&query.filter)?;
let total = count_ops_errors(pool, query.range, &query.filter).await?;
let offset = observability_page_offset(query.current_page, query.page_size)?;
let mut statement = QueryBuilder::<Postgres>::new("select * from (");
@@ -322,7 +322,7 @@ pub(crate) fn intermediate_attempt_from_row(
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: None,
upstream_send_state: None,
outcome: "failed".to_owned(),
outcome: admin_observability::RequestOutcome::Failed,
downstream_committed: false,
status_code: optional_status(row, "status_code")?,
provider_error_code: get(row, "provider_error_code")?,
@@ -460,11 +460,12 @@ impl AccountStore for PgAdminAccountStore {
) -> 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)
Ok(self
.usage_observations(range, account_ids)
.await?
.into_iter()
.map(admin_account_usage)
.collect()
.collect())
}
async fn load_account_usage_by_windows(
@@ -1,5 +1,9 @@
//! Admin 账号列表、用量与成本的领域映射
use crate::postgres::observability::{
admin_account_model_usage, admin_cost_coverage as admin_account_cost_coverage,
};
use super::*;
pub(crate) fn admin_account_record(
@@ -75,10 +79,8 @@ pub(crate) fn provider_document_json(document: ProviderDocument) -> StoreResult<
)
}
pub(crate) fn admin_account_usage(
usage: ProviderAccountUsageObservation,
) -> AdminStoreResult<AccountUsage> {
Ok(AccountUsage {
pub(crate) fn admin_account_usage(usage: ProviderAccountUsageObservation) -> AccountUsage {
AccountUsage {
account_id: usage.account_id,
request_count: usage.request_count,
success_count: usage.success_count,
@@ -93,22 +95,15 @@ pub(crate) fn admin_account_usage(
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)?,
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(),
request_buckets: usage.request_buckets,
models: usage
.models
.into_iter()
.map(admin_account_model_usage)
.collect::<AdminStoreResult<Vec<_>>>()?,
})
.collect(),
}
}
pub(crate) fn admin_account_usage_window(
@@ -271,59 +266,3 @@ pub(crate) fn invalid_window_usage(column: &'static str) -> AdminStoreError {
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()
}
@@ -11,7 +11,7 @@ use gateway_admin::{
accounts::{
AccountCost, AccountGroupFilter, AccountImportSettings,
AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
AccountPageItem, AccountRecord, AccountRequestBucket, AccountSort as AdminAccountSort,
AccountPageItem, AccountRecord, AccountSort as AdminAccountSort,
AccountSortField as AdminAccountSortField, AccountSummary, AccountUpdateResult,
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
BatchUpdateAccounts, DeleteAccounts, SortDirection as AdminSortDirection,
@@ -51,9 +51,8 @@ use crate::{
};
use super::{
AdminAuditEvent, ControlPlaneRepository, CurrencyCostTotal, ObservabilityRange,
ObservabilityRepository, PgControlPlaneRepository, PgObservabilityRepository,
ProviderAccountModelUsageObservation, ProviderAccountUsageObservation,
AdminAuditEvent, ControlPlaneRepository, ObservabilityRange, ObservabilityRepository,
PgControlPlaneRepository, PgObservabilityRepository, ProviderAccountUsageObservation,
ProviderAccountUsageQuery, append_admin_audit_event_in_transaction,
bump_config_revision_in_transaction, completed_usage_fact_predicate,
};
@@ -9,52 +9,24 @@ use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, Transaction};
use gateway_core::account::RotationStrategy;
use gateway_core::policy::CodexClientVersion;
use gateway_core::provider_ports::{
ProviderFreezePolicy, ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError,
ProviderStoreErrorKind, ProviderWarmupPolicy,
};
use gateway_admin::model::settings::RuntimeSettingsValues;
use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
#[derive(Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub values: RuntimeSettingsValues,
pub request_profiles:
BTreeMap<gateway_core::routing::ProviderKind, gateway_core::account::OpaqueProviderData>,
pub config_revision: Revision,
pub admin_api_key: Option<String>,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: BTreeMap<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub updated_at: DateTime<Utc>,
}
@@ -68,56 +40,9 @@ impl fmt::Debug for RuntimeSettings {
"admin_api_key",
&self.admin_api_key.as_ref().map(|_| "[REDACTED]"),
)
.field("refresh_margin_seconds", &self.refresh_margin_seconds)
.field("refresh_concurrency", &self.refresh_concurrency)
.field(
"max_concurrent_per_account",
&self.max_concurrent_per_account,
)
.field("request_interval_ms", &self.request_interval_ms)
.field("values", &self.values)
.field("rotation_strategy", &self.rotation_strategy)
.field("request_location_enabled", &self.request_location_enabled)
.field("request_location", &self.request_location)
.field("model_mappings", &self.model_mappings)
.field("min_codex_desktop_version", &self.min_codex_desktop_version)
.field("min_codex_cli_version", &self.min_codex_cli_version)
.field("usage_retention_days", &self.usage_retention_days)
.field("ops_event_retention_days", &self.ops_event_retention_days)
.field("audit_retention_days", &self.audit_retention_days)
.field(
"account_auto_freeze_enabled",
&self.account_auto_freeze_enabled,
)
.field(
"account_auto_freeze_threshold",
&self.account_auto_freeze_threshold,
)
.field(
"account_auto_freeze_window_seconds",
&self.account_auto_freeze_window_seconds,
)
.field(
"account_auto_freeze_duration_seconds",
&self.account_auto_freeze_duration_seconds,
)
.field(
"account_auto_freeze_probe_enabled",
&self.account_auto_freeze_probe_enabled,
)
.field(
"account_auto_freeze_probe_model",
&self.account_auto_freeze_probe_model,
)
.field(
"account_auto_freeze_adaptive_concurrency",
&self.account_auto_freeze_adaptive_concurrency,
)
.field("account_warmup_enabled", &self.account_warmup_enabled)
.field(
"account_warmup_schedule_time",
&self.account_warmup_schedule_time,
)
.field("account_warmup_model", &self.account_warmup_model)
.field("updated_at", &self.updated_at)
.finish()
}
@@ -125,42 +50,13 @@ impl fmt::Debug for RuntimeSettings {
#[derive(Clone)]
pub struct RuntimeSettingsUpdate {
pub values: RuntimeSettingsValues,
pub request_profile_updates: BTreeMap<
gateway_core::routing::ProviderKind,
Option<gateway_core::account::OpaqueProviderData>,
>,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: BTreeMap<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
impl fmt::Debug for RuntimeSettingsUpdate {
@@ -168,8 +64,11 @@ impl fmt::Debug for RuntimeSettingsUpdate {
formatter
.debug_struct("RuntimeSettingsUpdate")
.field("rotation_strategy", &self.rotation_strategy)
.field("request_location_enabled", &self.request_location_enabled)
.field("request_location", &self.request_location)
.field(
"request_location_enabled",
&self.values.request_location_enabled,
)
.field("request_location", &self.values.request_location)
.field("model_mappings", &self.model_mappings)
.finish_non_exhaustive()
}
@@ -177,33 +76,14 @@ impl fmt::Debug for RuntimeSettingsUpdate {
impl RuntimeSettingsUpdate {
pub fn validate(&self) -> StoreResult<()> {
if self.request_location.validate().is_err()
|| self.responses_max_decompressed_body_bytes == 0
|| isize::try_from(self.responses_max_decompressed_body_bytes).is_err()
|| self.refresh_margin_seconds == 0
|| self.refresh_concurrency == 0
|| self.max_waiting_per_key > 1_000
|| self.max_waiting_per_account > 1_000
|| !(1..=120).contains(&self.concurrency_wait_timeout_seconds)
|| self.usage_retention_days < 31
|| self.ops_event_retention_days == 0
|| self.audit_retention_days == 0
|| !(2..=1_000).contains(&self.account_auto_freeze_threshold)
|| !(60..=3_600).contains(&self.account_auto_freeze_window_seconds)
|| !(300..=604_800).contains(&self.account_auto_freeze_duration_seconds)
|| !valid_model_mappings(&self.model_mappings)
|| !valid_client_version(self.min_codex_desktop_version.as_deref())
|| !valid_client_version(self.min_codex_cli_version.as_deref())
|| !valid_probe_model(self.account_auto_freeze_probe_model.as_deref())
|| !gateway_core::provider_ports::valid_warmup_schedule_time(
&self.account_warmup_schedule_time,
if self.values.validate().is_err()
|| gateway_admin::model::settings::validate_model_mappings(
self.model_mappings
.iter()
.map(|(requested, upstream)| (requested.as_str(), upstream.as_str())),
)
|| !valid_probe_model(self.account_warmup_model.as_deref())
|| (self.account_warmup_enabled && self.account_warmup_model.is_none())
.is_err()
|| RotationStrategy::parse(&self.rotation_strategy).is_none()
|| self.max_account_rotations > gateway_core::account::MAX_ACCOUNT_ROTATIONS
|| !(1..=gateway_core::account::MAX_SESSION_AFFINITY_TTL_HOURS)
.contains(&self.openai_session_affinity_ttl_hours)
|| self.request_profile_updates.len() > 256
|| self
.request_profile_updates
@@ -400,10 +280,10 @@ impl ProviderRuntimePolicyPort for PgRuntimeSettingsRepository {
let settings = RuntimeSettingsRepository::load_runtime_settings(self)
.await
.map_err(|source| crate::provider_unavailable("load refresh policy", source))?;
let concurrency = NonZeroU32::new(settings.refresh_concurrency)
let concurrency = NonZeroU32::new(settings.values.refresh_concurrency)
.ok_or_else(|| provider_invalid("decode refresh policy"))?;
ProviderRefreshPolicy::try_new(
Duration::from_secs(settings.refresh_margin_seconds),
Duration::from_secs(settings.values.refresh_margin_seconds),
concurrency,
)
})
@@ -417,13 +297,13 @@ impl ProviderRuntimePolicyPort for PgRuntimeSettingsRepository {
.await
.map_err(|source| crate::provider_unavailable("load freeze policy", source))?;
ProviderFreezePolicy::try_new(
settings.account_auto_freeze_enabled,
settings.account_auto_freeze_threshold,
settings.account_auto_freeze_window_seconds,
settings.account_auto_freeze_duration_seconds,
settings.account_auto_freeze_probe_enabled,
settings.account_auto_freeze_probe_model,
settings.account_auto_freeze_adaptive_concurrency,
settings.values.account_auto_freeze_enabled,
settings.values.account_auto_freeze_threshold,
settings.values.account_auto_freeze_window_seconds,
settings.values.account_auto_freeze_duration_seconds,
settings.values.account_auto_freeze_probe_enabled,
settings.values.account_auto_freeze_probe_model,
settings.values.account_auto_freeze_adaptive_concurrency,
)
})
}
@@ -436,9 +316,9 @@ impl ProviderRuntimePolicyPort for PgRuntimeSettingsRepository {
.await
.map_err(|source| crate::provider_unavailable("load warmup policy", source))?;
ProviderWarmupPolicy::try_new(
settings.account_warmup_enabled,
settings.account_warmup_schedule_time,
settings.account_warmup_model,
settings.values.account_warmup_enabled,
settings.values.account_warmup_schedule_time,
settings.values.account_warmup_model,
)
})
}
@@ -477,7 +357,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
) -> StoreResult<Revision> {
update.validate()?;
let refresh_margin_seconds =
i64::try_from(update.refresh_margin_seconds).map_err(|_| invalid_numeric())?;
i64::try_from(update.values.refresh_margin_seconds).map_err(|_| invalid_numeric())?;
let request_profile_updates = update
.request_profile_updates
.iter()
@@ -537,51 +417,50 @@ pub(crate) async fn update_runtime_settings_in_transaction(
returning config_revision",
)
.bind(refresh_margin_seconds)
.bind(i64::from(update.refresh_concurrency))
.bind(i64::from(update.max_concurrent_per_account))
.bind(i64::try_from(update.request_interval_ms).map_err(|_| invalid_numeric())?)
.bind(i64::from(update.values.refresh_concurrency))
.bind(i64::from(update.values.max_concurrent_per_account))
.bind(i64::try_from(update.values.request_interval_ms).map_err(|_| invalid_numeric())?)
.bind(&update.rotation_strategy)
.bind(sqlx::types::Json(&update.model_mappings))
.bind(i64::from(update.usage_retention_days))
.bind(i64::from(update.ops_event_retention_days))
.bind(i64::from(update.audit_retention_days))
.bind(update.min_codex_desktop_version.as_deref())
.bind(update.min_codex_cli_version.as_deref())
.bind(i64::from(update.max_waiting_per_key))
.bind(i64::from(update.max_waiting_per_account))
.bind(i64::from(update.concurrency_wait_timeout_seconds))
.bind(update.account_auto_freeze_enabled)
.bind(i64::from(update.account_auto_freeze_threshold))
.bind(i64::try_from(update.account_auto_freeze_window_seconds).map_err(|_| invalid_numeric())?)
.bind(i64::from(update.values.usage_retention_days))
.bind(i64::from(update.values.ops_event_retention_days))
.bind(i64::from(update.values.audit_retention_days))
.bind(update.values.min_codex_desktop_version.as_deref())
.bind(update.values.min_codex_cli_version.as_deref())
.bind(i64::from(update.values.max_waiting_per_key))
.bind(i64::from(update.values.max_waiting_per_account))
.bind(i64::from(update.values.concurrency_wait_timeout_seconds))
.bind(update.values.account_auto_freeze_enabled)
.bind(i64::from(update.values.account_auto_freeze_threshold))
.bind(i64::try_from(update.values.account_auto_freeze_window_seconds).map_err(|_| invalid_numeric())?)
.bind(
i64::try_from(update.account_auto_freeze_duration_seconds)
i64::try_from(update.values.account_auto_freeze_duration_seconds)
.map_err(|_| invalid_numeric())?,
)
.bind(update.account_auto_freeze_probe_enabled)
.bind(update.account_auto_freeze_probe_model.as_deref())
.bind(update.account_auto_freeze_adaptive_concurrency)
.bind(update.values.account_auto_freeze_probe_enabled)
.bind(update.values.account_auto_freeze_probe_model.as_deref())
.bind(update.values.account_auto_freeze_adaptive_concurrency)
.bind(sqlx::types::Json(
update
.request_location
update.values.request_location
.clone()
.normalized()
.map_err(|_| invalid_location())?,
))
.bind(update.request_location_enabled)
.bind(update.values.request_location_enabled)
.bind(
i64::try_from(update.responses_max_decompressed_body_bytes)
i64::try_from(update.values.responses_max_decompressed_body_bytes)
.map_err(|_| invalid_numeric())?,
)
.bind(request_profile_deletions)
.bind(sqlx::types::Json(request_profile_updates))
.bind(update.account_warmup_enabled)
.bind(&update.account_warmup_schedule_time)
.bind(update.account_warmup_model.as_deref())
.bind(sqlx::types::Json(update.smart_scheduling))
.bind(i64::from(update.openai_guardian_reserved_concurrency))
.bind(update.openai_account_affinity.as_str())
.bind(i64::from(update.max_account_rotations))
.bind(i64::from(update.openai_session_affinity_ttl_hours))
.bind(update.values.account_warmup_enabled)
.bind(&update.values.account_warmup_schedule_time)
.bind(update.values.account_warmup_model.as_deref())
.bind(sqlx::types::Json(update.values.smart_scheduling))
.bind(i64::from(update.values.openai_guardian_reserved_concurrency))
.bind(update.values.openai_account_affinity.as_str())
.bind(i64::from(update.values.max_account_rotations))
.bind(i64::from(update.values.openai_session_affinity_ttl_hours))
.fetch_optional(&mut **transaction)
.await
.map_err(|source| postgres_unavailable("update runtime settings in transaction", source))?
@@ -691,50 +570,54 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
request_profiles,
config_revision: Revision::new(to_u64(row.config_revision)?)?,
admin_api_key: row.admin_api_key,
refresh_margin_seconds: to_u64(row.refresh_margin_seconds)?,
refresh_concurrency: to_u32(row.refresh_concurrency)?,
max_concurrent_per_account: to_u32(row.max_concurrent_per_account)?,
request_interval_ms: to_u64(row.request_interval_ms)?,
smart_scheduling: row.smart_scheduling_json.0,
rotation_strategy: row.rotation_strategy,
request_location_enabled: row.request_location_enabled,
request_location: row
.request_location_json
.0
.normalized()
.map_err(|_| invalid_location())?,
model_mappings: row.model_mappings_json.0,
usage_retention_days: to_u32(row.usage_retention_days)?,
ops_event_retention_days: to_u32(row.ops_event_retention_days)?,
audit_retention_days: to_u32(row.audit_retention_days)?,
min_codex_desktop_version: row.min_codex_desktop_version,
min_codex_cli_version: row.min_codex_cli_version,
updated_at: row.updated_at,
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
openai_guardian_reserved_concurrency: to_u32(row.openai_guardian_reserved_concurrency)?,
openai_account_affinity: gateway_core::account::AccountAffinity::parse(
&row.openai_account_affinity,
)
.ok_or_else(|| StoreError::InvalidData {
entity: "runtime settings",
message: "invalid account affinity".to_owned(),
source: None,
})?,
max_account_rotations: to_u32(row.max_account_rotations)?,
openai_session_affinity_ttl_hours: to_u32(row.openai_session_affinity_ttl_hours)?,
responses_max_decompressed_body_bytes: to_u64(row.responses_max_decompressed_body_bytes)?,
account_auto_freeze_enabled: row.account_auto_freeze_enabled,
account_auto_freeze_threshold: to_u32(row.account_auto_freeze_threshold)?,
account_auto_freeze_window_seconds: to_u64(row.account_auto_freeze_window_seconds)?,
account_auto_freeze_duration_seconds: to_u64(row.account_auto_freeze_duration_seconds)?,
account_auto_freeze_probe_enabled: row.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: row.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: row.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: row.account_warmup_enabled,
account_warmup_schedule_time: row.account_warmup_schedule_time,
account_warmup_model: row.account_warmup_model,
values: gateway_admin::model::settings::RuntimeSettingsValues {
refresh_margin_seconds: to_u64(row.refresh_margin_seconds)?,
refresh_concurrency: to_u32(row.refresh_concurrency)?,
max_concurrent_per_account: to_u32(row.max_concurrent_per_account)?,
request_interval_ms: to_u64(row.request_interval_ms)?,
smart_scheduling: row.smart_scheduling_json.0,
request_location_enabled: row.request_location_enabled,
request_location: row
.request_location_json
.0
.normalized()
.map_err(|_| invalid_location())?,
usage_retention_days: to_u32(row.usage_retention_days)?,
ops_event_retention_days: to_u32(row.ops_event_retention_days)?,
audit_retention_days: to_u32(row.audit_retention_days)?,
min_codex_desktop_version: row.min_codex_desktop_version,
min_codex_cli_version: row.min_codex_cli_version,
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
openai_guardian_reserved_concurrency: to_u32(row.openai_guardian_reserved_concurrency)?,
openai_account_affinity: gateway_core::account::AccountAffinity::parse(
&row.openai_account_affinity,
)
.ok_or_else(|| StoreError::InvalidData {
entity: "runtime settings",
message: "invalid account affinity".to_owned(),
source: None,
})?,
max_account_rotations: to_u32(row.max_account_rotations)?,
openai_session_affinity_ttl_hours: to_u32(row.openai_session_affinity_ttl_hours)?,
responses_max_decompressed_body_bytes: to_u64(
row.responses_max_decompressed_body_bytes,
)?,
account_auto_freeze_enabled: row.account_auto_freeze_enabled,
account_auto_freeze_threshold: to_u32(row.account_auto_freeze_threshold)?,
account_auto_freeze_window_seconds: to_u64(row.account_auto_freeze_window_seconds)?,
account_auto_freeze_duration_seconds: to_u64(row.account_auto_freeze_duration_seconds)?,
account_auto_freeze_probe_enabled: row.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: row.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: row.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: row.account_warmup_enabled,
account_warmup_schedule_time: row.account_warmup_schedule_time,
account_warmup_model: row.account_warmup_model,
},
})
}
@@ -777,29 +660,3 @@ fn provider_invalid(operation: &'static str) -> ProviderStoreError {
fn provider_conflict(operation: &'static str) -> ProviderStoreError {
ProviderStoreError::new(ProviderStoreErrorKind::Conflict, operation)
}
fn valid_model_mappings(mappings: &BTreeMap<String, String>) -> bool {
mappings.len() <= 512
&& mappings.iter().all(|(requested, upstream)| {
valid_model_name(requested, 256) && valid_model_name(upstream, 256)
})
}
fn valid_model_name(value: &str, max_len: usize) -> bool {
!value.is_empty()
&& value.len() <= max_len
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
fn valid_probe_model(value: Option<&str>) -> bool {
value.is_none_or(|value| {
!value.is_empty()
&& value.len() <= 128
&& value == value.trim()
&& !value.bytes().any(|byte| byte.is_ascii_control())
})
}
@@ -18,37 +18,11 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
use super::ClientApiKeySnapshot;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotRuntimeSettings {
pub pricing: gateway_core::metering::PricingOverrides,
pub request_profiles:
BTreeMap<gateway_core::routing::ProviderKind, gateway_core::account::OpaqueProviderData>,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
pub openai_session_affinity_ttl_hours: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub model_mappings: BTreeMap<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSnapshotData {
pub config_revision: Revision,
pub observed_current_revision: Revision,
pub settings: SnapshotRuntimeSettings,
pub settings: SettingsValues,
pub client_api_keys: Vec<ClientApiKeySnapshot>,
pub account_groups: Vec<SnapshotAccountGroupData>,
pub provider_accounts: Vec<SnapshotProviderAccountData>,
@@ -159,35 +133,7 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
.map_err(|_| SnapshotStoreError::unavailable())?;
let config_revision = core_revision(data.config_revision)?;
let observed_current_revision = core_revision(data.observed_current_revision)?;
let settings = SettingsValues::new(
data.settings.max_concurrent_per_account,
data.settings.request_interval_ms,
data.settings.rotation_strategy,
data.settings.model_mappings,
data.settings.min_codex_desktop_version,
data.settings.min_codex_cli_version,
)
.with_responses_max_decompressed_body_bytes(
data.settings.responses_max_decompressed_body_bytes,
)
.with_openai_guardian_reserved_concurrency(
data.settings.openai_guardian_reserved_concurrency,
)
.with_openai_account_affinity(data.settings.openai_account_affinity)
.with_max_account_rotations(data.settings.max_account_rotations)
.with_openai_session_affinity_ttl_hours(data.settings.openai_session_affinity_ttl_hours)
.with_smart_scheduling(data.settings.smart_scheduling)
.with_request_profiles(data.settings.request_profiles)
.with_pricing(data.settings.pricing)
.with_request_location(
data.settings.request_location,
data.settings.request_location_enabled,
)
.with_concurrency_queues(
data.settings.max_waiting_per_key,
data.settings.max_waiting_per_account,
data.settings.concurrency_wait_timeout_seconds,
);
let settings = data.settings;
let client_policies = data
.client_api_keys
.into_iter()
@@ -265,8 +211,6 @@ struct SnapshotSettingsRow {
pricing_synced_json: sqlx::types::Json<gateway_core::metering::PricingOverrides>,
pricing_overrides_json: sqlx::types::Json<gateway_core::metering::PricingOverrides>,
config_revision: i64,
refresh_margin_seconds: i64,
refresh_concurrency: i64,
max_concurrent_per_account: i64,
request_interval_ms: i64,
smart_scheduling_json: sqlx::types::Json<gateway_core::account::SmartSchedulingConfig>,
@@ -290,9 +234,9 @@ struct SnapshotSettingsRow {
async fn load_settings(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<(Revision, SnapshotRuntimeSettings)> {
) -> StoreResult<(Revision, SettingsValues)> {
let row = sqlx::query_as::<_, SnapshotSettingsRow>(
"select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
"select config_revision, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
.await
@@ -304,41 +248,44 @@ async fn load_settings(
})?;
Ok((
revision_from_i64(row.config_revision)?,
SnapshotRuntimeSettings {
pricing: {
super::pricing::validate_pricing(&row.pricing_synced_json.0)?;
super::pricing::validate_pricing(&row.pricing_overrides_json.0)?;
gateway_core::metering::merge_pricing(
row.pricing_synced_json.0,
&row.pricing_overrides_json.0,
)
},
request_profiles: decode_request_profiles(row.provider_request_profiles_json.0)?,
responses_max_decompressed_body_bytes: to_u64(
row.responses_max_decompressed_body_bytes,
)?,
request_location_enabled: row.request_location_enabled,
request_location: row.request_location_json.0,
refresh_margin_seconds: to_u64(row.refresh_margin_seconds)?,
refresh_concurrency: to_u32(row.refresh_concurrency)?,
max_concurrent_per_account: to_u32(row.max_concurrent_per_account)?,
request_interval_ms: to_u64(row.request_interval_ms)?,
smart_scheduling: row.smart_scheduling_json.0,
rotation_strategy: row.rotation_strategy,
model_mappings: row.model_mappings_json.0,
min_codex_desktop_version: row.min_codex_desktop_version,
min_codex_cli_version: row.min_codex_cli_version,
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
openai_guardian_reserved_concurrency: to_u32(row.openai_guardian_reserved_concurrency)?,
openai_account_affinity: gateway_core::account::AccountAffinity::parse(
&row.openai_account_affinity,
SettingsValues::new(
to_u32(row.max_concurrent_per_account)?,
to_u64(row.request_interval_ms)?,
row.rotation_strategy,
row.model_mappings_json.0,
row.min_codex_desktop_version,
row.min_codex_cli_version,
)
.with_responses_max_decompressed_body_bytes(to_u64(
row.responses_max_decompressed_body_bytes,
)?)
.with_openai_guardian_reserved_concurrency(to_u32(
row.openai_guardian_reserved_concurrency,
)?)
.with_openai_account_affinity(
gateway_core::account::AccountAffinity::parse(&row.openai_account_affinity)
.ok_or_else(|| invalid("invalid account affinity"))?,
)
.with_max_account_rotations(to_u32(row.max_account_rotations)?)
.with_openai_session_affinity_ttl_hours(to_u32(row.openai_session_affinity_ttl_hours)?)
.with_smart_scheduling(row.smart_scheduling_json.0)
.with_request_profiles(decode_request_profiles(
row.provider_request_profiles_json.0,
)?)
.with_pricing({
super::pricing::validate_pricing(&row.pricing_synced_json.0)?;
super::pricing::validate_pricing(&row.pricing_overrides_json.0)?;
gateway_core::metering::merge_pricing(
row.pricing_synced_json.0,
&row.pricing_overrides_json.0,
)
.ok_or_else(|| invalid("invalid account affinity"))?,
max_account_rotations: to_u32(row.max_account_rotations)?,
openai_session_affinity_ttl_hours: to_u32(row.openai_session_affinity_ttl_hours)?,
},
})
.with_request_location(row.request_location_json.0, row.request_location_enabled)
.with_concurrency_queues(
to_u32(row.max_waiting_per_key)?,
to_u32(row.max_waiting_per_account)?,
to_u32(row.concurrency_wait_timeout_seconds)?,
),
))
}
+1 -53
View File
@@ -178,59 +178,7 @@ impl Revision {
}
}
/// `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 {
source: None,
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))
}
}
pub use gateway_admin::model::observability::DecimalAmount;
/// Provider-owned JSON object
/// Store 只验证 object 与大小,不解释内部 key
@@ -5,8 +5,7 @@ use std::time::{Duration as StdDuration, SystemTime};
use chrono::{DateTime, Duration, Utc};
use futures::TryStreamExt as _;
use gateway_admin::{
model::{PageSize, observability as admin_observability},
ports::store::ObservabilityStore as _,
model::observability as admin_observability, ports::store::ObservabilityStore as _,
};
use gateway_core::diagnostics::TraceContext;
use gateway_core::engine::{
@@ -1597,7 +1596,7 @@ async fn zero_attempt_failure_is_queryable_without_fabricating_upstream_facts()
.usage_record_detail(request.id.as_str())
.await
.expect("load failed request detail");
assert_eq!(detail.request.outcome, "failed");
assert_eq!(detail.request.outcome.as_str(), "failed");
assert_eq!(detail.request.attempt_count, 0);
assert_eq!(detail.request.upstream_send_state, "not_sent");
assert_eq!(detail.request.client_api_key_ref, "key_zero_attempt");
@@ -1727,7 +1726,7 @@ async fn zero_attempt_failure_is_queryable_without_fabricating_upstream_facts()
..Default::default()
},
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("admin ops errors without provider facts");
@@ -1919,7 +1918,7 @@ async fn zero_attempt_recovery_respects_deadline_and_does_not_invent_a_trace() {
.usage_record_detail(request.id.as_str())
.await
.expect("recovered detail");
assert_eq!(detail.request.outcome, "incomplete");
assert_eq!(detail.request.outcome.as_str(), "incomplete");
assert_eq!(
detail.request.error_kind.as_deref(),
Some("process_interrupted")
@@ -1950,7 +1949,8 @@ async fn zero_attempt_recovery_respects_deadline_and_does_not_invent_a_trace() {
.await
.expect("active detail")
.request
.outcome,
.outcome
.as_str(),
"running"
);
database.close().await;
@@ -2093,3 +2093,74 @@ async fn entry_rejection_is_visible_without_a_fictitious_model_execution() {
assert_eq!(count, 0);
database.close().await;
}
#[tokio::test]
async fn shared_core_usage_and_timings_preserve_fields_and_reject_invalid_phases() {
let Some(database) = TestDatabase::create("shared_execution_facts").await else {
return;
};
let id = "req_shared_facts";
seed_running_request(&database.pool, id).await.unwrap();
let store = PgExecutionStore::new(database.pool.clone());
let mut finalization = successful_core_finalization(id);
finalization.usage = Usage {
input_tokens: Some(101),
output_tokens: Some(22),
cached_tokens: Some(33),
cache_write_tokens: Some(44),
reasoning_tokens: Some(5),
image_input_tokens: Some(6),
image_output_tokens: Some(7),
total_tokens: Some(123),
};
finalization.timings = CoreModelRequestTimings {
transport_decision_wait_ms: Some(1),
connect_ms: Some(2),
headers_ms: Some(3),
first_event_ms: Some(4),
first_reasoning_ms: Some(5),
first_text_ms: Some(6),
first_token_ms: Some(7),
provider_processing_ms: Some(8),
latency_ms: Some(9),
};
ExecutionStore::finalize_model_request(&store, finalization)
.await
.unwrap();
let persisted = stored_row(&database.pool, id).await;
for (field, expected) in [
("input_tokens", 101),
("output_tokens", 22),
("cached_tokens", 33),
("cache_write_tokens", 44),
("reasoning_tokens", 5),
("image_input_tokens", 6),
("image_output_tokens", 7),
("total_tokens", 123),
("transport_decision_wait_ms", 1),
("connect_ms", 2),
("headers_ms", 3),
("first_event_ms", 4),
("first_reasoning_ms", 5),
("first_text_ms", 6),
("first_token_ms", 7),
("provider_processing_ms", 8),
("latency_ms", 9),
] {
assert_eq!(persisted[field], json!(expected), "{field}");
}
let invalid_id = "req_invalid_phase";
seed_running_request(&database.pool, invalid_id)
.await
.unwrap();
let before = stored_row(&database.pool, invalid_id).await;
let mut invalid = successful_core_finalization(invalid_id);
invalid.timings.latency_ms = Some(10);
invalid.timings.first_token_ms = Some(11);
let error = ExecutionStore::finalize_model_request(&store, invalid)
.await
.unwrap_err();
assert_eq!(error.kind(), StoreErrorKind::InvalidData);
assert_eq!(stored_row(&database.pool, invalid_id).await, before);
database.close().await;
}
@@ -668,7 +668,7 @@ async fn zero_attempt_create_and_finalize_drain_in_order_with_trace() {
.usage_record_detail(request.id.as_str())
.await
.expect("queued failure detail");
assert_eq!(detail.request.outcome, "failed");
assert_eq!(detail.request.outcome.as_str(), "failed");
assert_eq!(detail.request.attempt_count, 0);
assert_eq!(detail.request.upstream_send_state, "not_sent");
assert_eq!(detail.trace, Some(expected_trace));
@@ -768,7 +768,7 @@ async fn zero_attempt_dropped_finalize_is_fail_open_and_recoverable_at_deadline(
.usage_record_detail(request.id.as_str())
.await
.expect("pending record");
assert_eq!(pending.request.outcome, "running");
assert_eq!(pending.request.outcome.as_str(), "running");
assert_eq!(pending.trace, None);
assert!(pending.attempts.is_empty());
assert_eq!(
@@ -783,7 +783,7 @@ async fn zero_attempt_dropped_finalize_is_fail_open_and_recoverable_at_deadline(
.usage_record_detail(request.id.as_str())
.await
.expect("recovered record");
assert_eq!(recovered.request.outcome, "incomplete");
assert_eq!(recovered.request.outcome.as_str(), "incomplete");
assert_eq!(
recovered.request.error_kind.as_deref(),
Some("process_interrupted")
@@ -10,7 +10,7 @@ use std::{
use chrono::{TimeDelta, Utc};
use futures::future::BoxFuture;
use gateway_admin::{
model::{PageSize, observability as admin_observability},
model::observability as admin_observability,
ports::store::ObservabilityStore as AdminObservabilityStore,
};
use gateway_core::{
@@ -218,7 +218,7 @@ async fn usage_list_and_diagnostics_should_resolve_current_account_metadata_by_i
range,
filter: admin_observability::UsageFilter::default(),
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("usage list with current notes");
@@ -300,7 +300,7 @@ async fn usage_list_and_diagnostics_should_resolve_current_account_metadata_by_i
..admin_observability::UsageFilter::default()
},
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("deleted account history");
@@ -695,7 +695,7 @@ async fn ops_errors_should_use_each_event_accounts_current_subscription() {
.expect("ops range"),
filter: admin_observability::OpsErrorFilter::default(),
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
};
let store = admin_observability_store(&database.pool);
for plan in [Some("pro"), Some("plus"), None] {
@@ -797,7 +797,7 @@ async fn ops_should_include_incomplete_upstream_errors() {
..admin_observability::OpsErrorFilter::default()
},
current_page: 1,
page_size: PageSize::new(10).unwrap(),
page_size: ObservabilityPageSize::new(10).unwrap(),
})
.await
.expect("incomplete request must be available in admin error troubleshooting");
@@ -1396,7 +1396,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
range,
filter: admin_observability::UsageFilter::default(),
current_page: 1,
page_size: PageSize::new(1).expect("page size"),
page_size: ObservabilityPageSize::new(1).expect("page size"),
})
.await
.expect("first usage page");
@@ -1414,7 +1414,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
range,
filter: admin_observability::UsageFilter::default(),
current_page: 129,
page_size: PageSize::new(1).expect("page size"),
page_size: ObservabilityPageSize::new(1).expect("page size"),
})
.await
.expect("direct deep usage page");
@@ -1442,7 +1442,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
search: Some("req_observe_success".to_owned()),
},
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("fully filtered usage page");
@@ -1461,7 +1461,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
..admin_observability::UsageFilter::default()
},
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("other outcome filter should reach PostgreSQL");
@@ -1541,7 +1541,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
..admin_observability::OpsErrorFilter::default()
},
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("admin ops errors");
@@ -1553,7 +1553,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
range,
filter: admin_observability::OpsErrorFilter::default(),
current_page: 129,
page_size: PageSize::new(1).expect("page size"),
page_size: ObservabilityPageSize::new(1).expect("page size"),
})
.await
.expect("direct deep ops page");
@@ -1593,7 +1593,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
range,
filter,
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("fully forwarded ops filter");
@@ -112,29 +112,27 @@ async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_
}
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone());
let frozen = snapshot.load_runtime_snapshot().await.unwrap();
assert_eq!(
frozen.settings.pricing["openai"]["model-a"].multiplier_bps,
20000
);
assert_eq!(
frozen.settings.pricing["openai"]["model-a"].bands,
pricing.bands
);
let frozen_pricing: gateway_core::metering::PricingOverrides =
serde_json::from_value(serde_json::to_value(&frozen.settings).unwrap()["pricing"].clone())
.unwrap();
assert_eq!(frozen_pricing["openai"]["model-a"].multiplier_bps, 20000);
assert_eq!(frozen_pricing["openai"]["model-a"].bands, pricing.bands);
settings
.update_pricing(update("model-a", PricingChange::Reset), &context)
.await
.unwrap();
let restored = snapshot.load_runtime_snapshot().await.unwrap();
let restored_pricing: gateway_core::metering::PricingOverrides = serde_json::from_value(
serde_json::to_value(&restored.settings).unwrap()["pricing"].clone(),
)
.unwrap();
assert_eq!(
restored.settings.pricing["openai"]["model-a"],
restored_pricing["openai"]["model-a"],
source["openai"]["model-a"]
);
assert_eq!(restored.settings.pricing["openai"]["model-b"], pricing);
assert_eq!(
frozen.settings.pricing["openai"]["model-a"].multiplier_bps,
20000
);
assert_eq!(restored_pricing["openai"]["model-b"], pricing);
assert_eq!(frozen_pricing["openai"]["model-a"].multiplier_bps, 20000);
let audits: i64 = sqlx::query_scalar("select count(*) from admin_audit_events where action in ('pricing.update', 'pricing.sync')")
.fetch_one(&database.pool).await.unwrap();
assert_eq!(audits, 6);
@@ -185,6 +183,9 @@ async fn deleting_pricing_removes_both_layers_and_preserves_other_models_and_fro
.unwrap();
let repository = PgRuntimeSnapshotRepository::new(database.pool.clone());
let frozen = repository.load_runtime_snapshot().await.unwrap();
let frozen_pricing: gateway_core::metering::PricingOverrides =
serde_json::from_value(serde_json::to_value(&frozen.settings).unwrap()["pricing"].clone())
.unwrap();
let synced_at = settings.load_pricing().await.unwrap().synced_at;
settings
.update_pricing(
@@ -212,9 +213,12 @@ async fn deleting_pricing_removes_both_layers_and_preserves_other_models_and_fro
);
assert_eq!(stored.synced_at, synced_at);
let current = repository.load_runtime_snapshot().await.unwrap();
assert_eq!(current.settings.pricing["openai"].len(), 1);
assert!(current.settings.pricing["xai"].contains_key("shared"));
assert_eq!(frozen.settings.pricing["openai"].len(), 4);
let current_pricing: gateway_core::metering::PricingOverrides =
serde_json::from_value(serde_json::to_value(&current.settings).unwrap()["pricing"].clone())
.unwrap();
assert_eq!(current_pricing["openai"].len(), 1);
assert!(current_pricing["xai"].contains_key("shared"));
assert_eq!(frozen_pricing["openai"].len(), 4);
let audits: i64 = sqlx::query_scalar(
"select count(*) from admin_audit_events where action = 'pricing.update'",
)
@@ -267,7 +271,11 @@ async fn concurrent_pricing_syncs_merge_selected_changes_and_remove_only_selecte
.load_runtime_snapshot()
.await
.unwrap();
assert!(snapshot.settings.pricing["xai"].contains_key("model-a"));
let snapshot_pricing: gateway_core::metering::PricingOverrides = serde_json::from_value(
serde_json::to_value(&snapshot.settings).unwrap()["pricing"].clone(),
)
.unwrap();
assert!(snapshot_pricing["xai"].contains_key("model-a"));
settings
.sync_pricing(patch(json!({"openai": {"model-a": null}})), &context)
.await
@@ -12,41 +12,43 @@ use super::TestDatabase;
fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
RuntimeSettingsUpdate {
request_profile_updates: BTreeMap::new(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: "smart".to_owned(),
model_mappings: BTreeMap::from([
("gpt-5.4".to_owned(), "gpt-5.5".to_owned()),
("grok-latest".to_owned(), "grok-4.5".to_owned()),
]),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
values: gateway_admin::model::settings::RuntimeSettingsValues {
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
},
}
}
@@ -71,30 +73,46 @@ async fn smart_settings_upgrade_preserves_selection_and_publishes_custom_config(
let before = repository.load_runtime_settings().await.unwrap();
assert_eq!(before.rotation_strategy, "sticky");
// 0022 将仍为旧默认 3600 的行迁移到 300;管理员自定义值才会原样保留。
assert_eq!(before.refresh_margin_seconds, 300);
assert_eq!(before.smart_scheduling, SmartSchedulingConfig::default());
assert_eq!(before.values.refresh_margin_seconds, 300);
assert_eq!(
before.values.smart_scheduling,
SmartSchedulingConfig::default()
);
let mut update = settings_with_margin(3600);
update.smart_scheduling =
update.values.smart_scheduling =
SmartSchedulingConfig::new([0.0, 2.0, 1.0, 0.5, 1.2, 2.3], true).unwrap();
let expected = update.smart_scheduling;
let expected = update.values.smart_scheduling;
repository.update_runtime_settings(update).await.unwrap();
let reloaded = repository.load_runtime_settings().await.unwrap();
assert_eq!(reloaded.smart_scheduling, expected);
assert_eq!(reloaded.values.smart_scheduling, expected);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert_eq!(snapshot.settings.smart_scheduling, expected);
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
assert_eq!(
snapshot_values["smart_scheduling"],
serde_json::to_value(expected).unwrap()
);
assert!(snapshot.config_revision > before.config_revision);
assert_eq!(before.smart_scheduling, SmartSchedulingConfig::default());
assert_eq!(
before.values.smart_scheduling,
SmartSchedulingConfig::default()
);
database.close().await;
}
#[test]
fn runtime_settings_require_model_when_warmup_is_enabled() {
let settings = RuntimeSettingsUpdate {
account_warmup_enabled: true,
..settings_with_margin(3_600)
let settings = {
let runtime_settings_base = settings_with_margin(3_600);
RuntimeSettingsUpdate {
values: gateway_admin::model::settings::RuntimeSettingsValues {
account_warmup_enabled: true,
..runtime_settings_base.values
},
..runtime_settings_base
}
};
assert!(settings.validate().is_err());
}
@@ -106,9 +124,9 @@ async fn warmup_settings_round_trip_with_database_constraint() {
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let mut update = settings_with_margin(3_600);
update.account_warmup_enabled = true;
update.account_warmup_schedule_time = "08:00,13:00".to_owned();
update.account_warmup_model = Some("test-model".to_owned());
update.values.account_warmup_enabled = true;
update.values.account_warmup_schedule_time = "08:00,13:00".to_owned();
update.values.account_warmup_model = Some("test-model".to_owned());
repository
.update_runtime_settings(update)
.await
@@ -118,9 +136,12 @@ async fn warmup_settings_round_trip_with_database_constraint() {
.load_runtime_settings()
.await
.expect("load warmup settings");
assert!(settings.account_warmup_enabled);
assert_eq!(settings.account_warmup_schedule_time, "08:00,13:00");
assert_eq!(settings.account_warmup_model.as_deref(), Some("test-model"));
assert!(settings.values.account_warmup_enabled);
assert_eq!(settings.values.account_warmup_schedule_time, "08:00,13:00");
assert_eq!(
settings.values.account_warmup_model.as_deref(),
Some("test-model")
);
let error = sqlx::query("update runtime_settings set account_warmup_model = null where id = 1")
.execute(&database.pool)
@@ -143,7 +164,7 @@ async fn unlimited_default_account_concurrency_round_trips_without_relaxing_othe
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let mut update = settings_with_margin(3_600);
update.max_concurrent_per_account = 0;
update.values.max_concurrent_per_account = 0;
repository
.update_runtime_settings(update)
.await
@@ -152,7 +173,7 @@ async fn unlimited_default_account_concurrency_round_trips_without_relaxing_othe
.load_runtime_settings()
.await
.expect("read unlimited default");
assert_eq!(settings.max_concurrent_per_account, 0);
assert_eq!(settings.values.max_concurrent_per_account, 0);
for statement in [
"update runtime_settings set max_concurrent_per_account = -1 where id = 1",
"update runtime_settings set refresh_concurrency = 0 where id = 1",
@@ -186,7 +207,7 @@ async fn migrations_should_narrow_the_default_refresh_margin_to_the_codex_baseli
.load_runtime_settings()
.await
.expect("load settings");
assert_eq!(settings.refresh_margin_seconds, 300);
assert_eq!(settings.values.refresh_margin_seconds, 300);
// 列默认值同步收窄,重建行不会回退到旧值;目录查询限定本测试 schema,
// 同库并行测试里停留在旧迁移版本的 schema 仍保留 3600 旧默认,不能被读到。
let column_default: String = sqlx::query_scalar(
@@ -221,7 +242,7 @@ async fn refresh_margin_migration_should_preserve_admin_customized_values() {
.load_runtime_settings()
.await
.expect("load settings");
assert_eq!(settings.refresh_margin_seconds, 1_800);
assert_eq!(settings.values.refresh_margin_seconds, 1_800);
database.close().await;
}
@@ -238,21 +259,45 @@ fn runtime_settings_reject_invalid_model_mapping() {
#[test]
fn runtime_settings_reject_out_of_range_auto_freeze() {
for update in [
RuntimeSettingsUpdate {
account_auto_freeze_threshold: 1,
..settings_with_margin(3_600)
{
let runtime_settings_base = settings_with_margin(3_600);
RuntimeSettingsUpdate {
values: gateway_admin::model::settings::RuntimeSettingsValues {
account_auto_freeze_threshold: 1,
..runtime_settings_base.values
},
..runtime_settings_base
}
},
RuntimeSettingsUpdate {
account_auto_freeze_window_seconds: 59,
..settings_with_margin(3_600)
{
let runtime_settings_base = settings_with_margin(3_600);
RuntimeSettingsUpdate {
values: gateway_admin::model::settings::RuntimeSettingsValues {
account_auto_freeze_window_seconds: 59,
..runtime_settings_base.values
},
..runtime_settings_base
}
},
RuntimeSettingsUpdate {
account_auto_freeze_duration_seconds: 299,
..settings_with_margin(3_600)
{
let runtime_settings_base = settings_with_margin(3_600);
RuntimeSettingsUpdate {
values: gateway_admin::model::settings::RuntimeSettingsValues {
account_auto_freeze_duration_seconds: 299,
..runtime_settings_base.values
},
..runtime_settings_base
}
},
RuntimeSettingsUpdate {
account_auto_freeze_probe_model: Some(" pad ".to_owned()),
..settings_with_margin(3_600)
{
let runtime_settings_base = settings_with_margin(3_600);
RuntimeSettingsUpdate {
values: gateway_admin::model::settings::RuntimeSettingsValues {
account_auto_freeze_probe_model: Some(" pad ".to_owned()),
..runtime_settings_base.values
},
..runtime_settings_base
}
},
] {
assert!(update.validate().is_err());
@@ -261,9 +306,15 @@ fn runtime_settings_reject_out_of_range_auto_freeze() {
#[test]
fn runtime_settings_reject_non_semver_client_min() {
let settings = RuntimeSettingsUpdate {
min_codex_cli_version: Some("v0.40.0".to_owned()),
..settings_with_margin(3_600)
let settings = {
let runtime_settings_base = settings_with_margin(3_600);
RuntimeSettingsUpdate {
values: gateway_admin::model::settings::RuntimeSettingsValues {
min_codex_cli_version: Some("v0.40.0".to_owned()),
..runtime_settings_base.values
},
..runtime_settings_base
}
};
assert!(settings.validate().is_err());
@@ -304,8 +355,8 @@ async fn client_min_versions_should_round_trip_as_nullable_settings() {
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let mut update = settings_with_margin(3_600);
update.min_codex_desktop_version = Some("26.825.6671".to_owned());
update.min_codex_cli_version = Some("0.40.0".to_owned());
update.values.min_codex_desktop_version = Some("26.825.6671".to_owned());
update.values.min_codex_cli_version = Some("0.40.0".to_owned());
repository
.update_runtime_settings(update)
@@ -317,10 +368,13 @@ async fn client_min_versions_should_round_trip_as_nullable_settings() {
.expect("load client min versions");
assert_eq!(
settings.min_codex_desktop_version.as_deref(),
settings.values.min_codex_desktop_version.as_deref(),
Some("26.825.6671")
);
assert_eq!(settings.min_codex_cli_version.as_deref(), Some("0.40.0"));
assert_eq!(
settings.values.min_codex_cli_version.as_deref(),
Some("0.40.0")
);
database.close().await;
}
@@ -416,26 +470,26 @@ async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() {
let before = repository.load_runtime_settings().await.unwrap();
assert_eq!(
(
before.max_waiting_per_key,
before.max_waiting_per_account,
before.concurrency_wait_timeout_seconds,
before.openai_guardian_reserved_concurrency,
before.values.max_waiting_per_key,
before.values.max_waiting_per_account,
before.values.concurrency_wait_timeout_seconds,
before.values.openai_guardian_reserved_concurrency,
),
(0, 0, 30, 0)
);
let mut update = settings_with_margin(3600);
update.max_waiting_per_key = 5;
update.max_waiting_per_account = 7;
update.concurrency_wait_timeout_seconds = 12;
update.openai_guardian_reserved_concurrency = 1;
update.values.max_waiting_per_key = 5;
update.values.max_waiting_per_account = 7;
update.values.concurrency_wait_timeout_seconds = 12;
update.values.openai_guardian_reserved_concurrency = 1;
repository.update_runtime_settings(update).await.unwrap();
let settings = repository.load_runtime_settings().await.unwrap();
assert_eq!(
(
settings.max_waiting_per_key,
settings.max_waiting_per_account,
settings.concurrency_wait_timeout_seconds,
settings.openai_guardian_reserved_concurrency,
settings.values.max_waiting_per_key,
settings.values.max_waiting_per_account,
settings.values.concurrency_wait_timeout_seconds,
settings.values.openai_guardian_reserved_concurrency,
),
(5, 7, 12, 1)
);
@@ -443,12 +497,17 @@ async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() {
.load_runtime_snapshot()
.await
.unwrap();
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
assert_eq!(
(
snapshot.settings.max_waiting_per_key,
snapshot.settings.max_waiting_per_account,
snapshot.settings.concurrency_wait_timeout_seconds,
snapshot.settings.openai_guardian_reserved_concurrency,
snapshot_values["max_waiting_per_key"].as_u64().unwrap(),
snapshot_values["max_waiting_per_account"].as_u64().unwrap(),
snapshot_values["concurrency_wait_timeout_seconds"]
.as_u64()
.unwrap(),
snapshot_values["openai_guardian_reserved_concurrency"]
.as_u64()
.unwrap(),
),
(5, 7, 12, 1)
);
@@ -465,53 +524,69 @@ async fn request_location_defaults_and_updates_reach_the_runtime_snapshot() {
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let before = repository.load_runtime_settings().await.unwrap();
assert_eq!(before.request_location, RequestLocation::default());
assert!(!before.request_location_enabled);
assert_eq!(before.values.request_location, RequestLocation::default());
assert!(!before.values.request_location_enabled);
let mut update = settings_with_margin(3600);
update.request_location = serde_json::from_value(serde_json::json!({"country":"JP", "region":" Tokyo ", "city":" Tokyo ", "timezone":"Asia/Tokyo"})).unwrap();
update.request_location_enabled = true;
update.account_auto_freeze_threshold = 17;
update.account_auto_freeze_window_seconds = 900;
update.account_auto_freeze_duration_seconds = 3_600;
update.account_auto_freeze_probe_model = Some("gpt-5.5".to_owned());
update.account_auto_freeze_adaptive_concurrency = false;
let expected = update.request_location.clone().normalized().unwrap();
update.values.request_location = serde_json::from_value(serde_json::json!({"country":"JP", "region":" Tokyo ", "city":" Tokyo ", "timezone":"Asia/Tokyo"})).unwrap();
update.values.request_location_enabled = true;
update.values.account_auto_freeze_threshold = 17;
update.values.account_auto_freeze_window_seconds = 900;
update.values.account_auto_freeze_duration_seconds = 3_600;
update.values.account_auto_freeze_probe_model = Some("gpt-5.5".to_owned());
update.values.account_auto_freeze_adaptive_concurrency = false;
let expected = update.values.request_location.clone().normalized().unwrap();
let mut disabled = update.clone();
disabled.request_location = expected.clone();
disabled.request_location_enabled = false;
disabled.values.request_location = expected.clone();
disabled.values.request_location_enabled = false;
repository.update_runtime_settings(update).await.unwrap();
let settings = repository.load_runtime_settings().await.unwrap();
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert_eq!(settings.request_location, expected);
assert_eq!(snapshot.settings.request_location, expected);
assert!(settings.request_location_enabled);
assert!(snapshot.settings.request_location_enabled);
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
assert_eq!(settings.values.request_location, expected);
assert_eq!(
snapshot_values["request_location"],
serde_json::to_value(&expected).unwrap()
);
assert!(settings.values.request_location_enabled);
assert!(
snapshot_values["request_location_enabled"]
.as_bool()
.unwrap()
);
assert!(snapshot.config_revision > before.config_revision);
repository.update_runtime_settings(disabled).await.unwrap();
let disabled_settings = repository.load_runtime_settings().await.unwrap();
assert!(!disabled_settings.request_location_enabled);
assert_eq!(disabled_settings.request_location, expected);
assert!(!disabled_settings.values.request_location_enabled);
assert_eq!(disabled_settings.values.request_location, expected);
let disabled_snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert!(!disabled_snapshot.settings.request_location_enabled);
assert_eq!(disabled_snapshot.settings.request_location, expected);
let disabled_snapshot_values = serde_json::to_value(&disabled_snapshot.settings).unwrap();
assert!(
!disabled_snapshot_values["request_location_enabled"]
.as_bool()
.unwrap()
);
assert_eq!(
disabled_snapshot_values["request_location"],
serde_json::to_value(&expected).unwrap()
);
// 位置开关与自动冻结共用设置写入,切换位置不能覆盖冻结参数
for saved in [&settings, &disabled_settings] {
assert!(saved.account_auto_freeze_enabled);
assert_eq!(saved.account_auto_freeze_threshold, 17);
assert_eq!(saved.account_auto_freeze_window_seconds, 900);
assert_eq!(saved.account_auto_freeze_duration_seconds, 3_600);
assert!(saved.account_auto_freeze_probe_enabled);
assert!(saved.values.account_auto_freeze_enabled);
assert_eq!(saved.values.account_auto_freeze_threshold, 17);
assert_eq!(saved.values.account_auto_freeze_window_seconds, 900);
assert_eq!(saved.values.account_auto_freeze_duration_seconds, 3_600);
assert!(saved.values.account_auto_freeze_probe_enabled);
assert_eq!(
saved.account_auto_freeze_probe_model.as_deref(),
saved.values.account_auto_freeze_probe_model.as_deref(),
Some("gpt-5.5")
);
assert!(!saved.account_auto_freeze_adaptive_concurrency);
assert!(!saved.values.account_auto_freeze_adaptive_concurrency);
}
assert!(disabled_snapshot.config_revision > snapshot.config_revision);
for invalid in [
@@ -532,6 +607,7 @@ async fn request_location_defaults_and_updates_reach_the_runtime_snapshot() {
.load_runtime_settings()
.await
.unwrap()
.values
.request_location,
expected
);
@@ -549,6 +625,7 @@ async fn auto_freeze_defaults_off_and_explicit_opt_in_round_trips() {
.load_runtime_settings()
.await
.expect("default settings")
.values
.account_auto_freeze_enabled
);
repository
@@ -560,6 +637,7 @@ async fn auto_freeze_defaults_off_and_explicit_opt_in_round_trips() {
.load_runtime_settings()
.await
.expect("settings")
.values
.account_auto_freeze_enabled
);
database.close().await;
@@ -574,32 +652,33 @@ async fn decompression_setting_should_persist_and_reach_snapshot_facts() {
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let before = repository.load_runtime_settings().await.unwrap();
assert_eq!(
before.responses_max_decompressed_body_bytes,
before.values.responses_max_decompressed_body_bytes,
64 * 1024 * 1024
);
let mut update = settings_with_margin(3600);
update.responses_max_decompressed_body_bytes = 128 * 1024 * 1024;
update.values.responses_max_decompressed_body_bytes = 128 * 1024 * 1024;
repository.update_runtime_settings(update).await.unwrap();
let reloaded = PgRuntimeSettingsRepository::new(database.pool.clone())
.load_runtime_settings()
.await
.unwrap();
assert_eq!(
reloaded.responses_max_decompressed_body_bytes,
reloaded.values.responses_max_decompressed_body_bytes,
128 * 1024 * 1024
);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
assert_eq!(
snapshot.settings.responses_max_decompressed_body_bytes,
reloaded.responses_max_decompressed_body_bytes
snapshot_values["responses_max_decompressed_body_bytes"],
reloaded.values.responses_max_decompressed_body_bytes
);
assert!(snapshot.config_revision > before.config_revision);
for invalid in [0, u64::MAX] {
let mut update = settings_with_margin(3600);
update.responses_max_decompressed_body_bytes = invalid;
update.values.responses_max_decompressed_body_bytes = invalid;
assert!(repository.update_runtime_settings(update).await.is_err());
assert_eq!(
repository
@@ -914,7 +993,10 @@ async fn control_plane_replacement_commits_one_writer_per_revision_and_preserves
));
let current = repository.load_control_plane().await.unwrap().settings;
assert_eq!(current.config_revision.get(), revision.get() + 1);
assert_eq!(current.refresh_margin_seconds, saved.refresh_margin_seconds);
assert_eq!(
current.values.refresh_margin_seconds,
saved.values.refresh_margin_seconds
);
let audit_count: i64 = sqlx::query_scalar(
"select count(*) from admin_audit_events where action = 'settings.replace'",
)
@@ -1047,10 +1129,13 @@ async fn runtime_scheduling_upgrade_preserves_settings_without_a_warmup_table()
.await
.unwrap();
assert_eq!(settings.rotation_strategy, "sticky");
assert!(settings.account_warmup_enabled);
assert_eq!(settings.account_warmup_schedule_time, "08:00,13:00");
assert_eq!(settings.account_warmup_model.as_deref(), Some("test-model"));
assert_eq!(settings.openai_guardian_reserved_concurrency, 0);
assert!(settings.values.account_warmup_enabled);
assert_eq!(settings.values.account_warmup_schedule_time, "08:00,13:00");
assert_eq!(
settings.values.account_warmup_model.as_deref(),
Some("test-model")
);
assert_eq!(settings.values.openai_guardian_reserved_concurrency, 0);
database.close().await;
}
@@ -1111,40 +1196,50 @@ async fn account_affinity_upgrade_defaults_and_updates_reach_the_snapshot() {
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let before = repository.load_runtime_settings().await.unwrap();
assert_eq!(before.rotation_strategy, "sticky");
assert_eq!(before.openai_account_affinity, AccountAffinity::Relaxed);
assert_eq!(before.max_account_rotations, 3);
assert_eq!(before.openai_session_affinity_ttl_hours, 24);
assert_eq!(
before.values.openai_account_affinity,
AccountAffinity::Relaxed
);
assert_eq!(before.values.max_account_rotations, 3);
assert_eq!(before.values.openai_session_affinity_ttl_hours, 24);
for (mode, budget, ttl) in [
(AccountAffinity::Strict, 31, 168),
(AccountAffinity::Relaxed, 0, 720),
] {
let mut update = settings_with_margin(3600);
update.openai_account_affinity = mode;
update.max_account_rotations = budget;
update.openai_session_affinity_ttl_hours = ttl;
update.values.openai_account_affinity = mode;
update.values.max_account_rotations = budget;
update.values.openai_session_affinity_ttl_hours = ttl;
repository.update_runtime_settings(update).await.unwrap();
let loaded = repository.load_runtime_settings().await.unwrap();
assert_eq!(
(loaded.openai_account_affinity, loaded.max_account_rotations),
(
loaded.values.openai_account_affinity,
loaded.values.max_account_rotations
),
(mode, budget)
);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
assert_eq!(
(
snapshot.settings.openai_account_affinity,
snapshot.settings.max_account_rotations
serde_json::from_value::<AccountAffinity>(
snapshot_values["openai_account_affinity"].clone()
)
.unwrap(),
snapshot_values["max_account_rotations"].as_u64().unwrap()
),
(mode, budget)
(mode, u64::from(budget))
);
assert!(snapshot.config_revision > before.config_revision);
assert_eq!(loaded.openai_session_affinity_ttl_hours, ttl);
assert_eq!(snapshot.settings.openai_session_affinity_ttl_hours, ttl);
assert_eq!(loaded.values.openai_session_affinity_ttl_hours, ttl);
assert_eq!(snapshot_values["openai_session_affinity_ttl_hours"], ttl);
}
let mut invalid = settings_with_margin(3600);
invalid.max_account_rotations = 32;
invalid.values.max_account_rotations = 32;
assert!(repository.update_runtime_settings(invalid).await.is_err());
for sql in [
"update runtime_settings set openai_session_affinity_ttl_hours = 0 where id = 1",
@@ -1160,6 +1255,7 @@ async fn account_affinity_upgrade_defaults_and_updates_reach_the_snapshot() {
.load_runtime_settings()
.await
.unwrap()
.values
.max_account_rotations,
0
);
+4
View File
@@ -732,6 +732,10 @@ HTTP validation
不构成所有写入的统一前置条件。运行设置整体替换必须携带读取版本,Store 在同一事务比较后才提交,
防止覆盖并发修改;插件、代理等资源使用各自的 `revision` / `expectedRevision`,不能与全局版本混用
运行设置的共同管理值由 Admin 定义,读取结果与替换命令显式组合这些值,各自持有版本和 Provider 画像的读写语义。
Core 只持有请求所需设置与策略约束;Store 负责 SQL 解码、密钥隔离和事务,API 负责 wire 类型与展示。
HTTP 与插件的平铺合同独立于宿主内部组合,SDK 从宿主声明生成,不手工维护第二份字段来源
额度、cooldown、目录 generation、请求统计和自动 credential refresh 属于运行时观测,不推进全局
revision;credential 轮换只推进账号自己的 `credential_revision`。Redis 通知用于缩短收敛延迟,
PostgreSQL 周期对账才是正确性基础