diff --git a/backend/crates/gateway-admin/src/freeze_recovery.rs b/backend/crates/gateway-admin/src/freeze_recovery.rs index f603023a0..61eae5db4 100644 --- a/backend/crates/gateway-admin/src/freeze_recovery.rs +++ b/backend/crates/gateway-admin/src/freeze_recovery.rs @@ -66,13 +66,13 @@ impl FreezeRecoveryTask { async fn freeze_policy(&self) -> Option { 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() } diff --git a/backend/crates/gateway-admin/src/model/account_groups.rs b/backend/crates/gateway-admin/src/model/account_groups.rs index ad5be2f20..354802afe 100644 --- a/backend/crates/gateway-admin/src/model/account_groups.rs +++ b/backend/crates/gateway-admin/src/model/account_groups.rs @@ -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); diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index 375eedd93..6a51d6629 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -133,40 +133,10 @@ pub struct AccountRecord { pub updated_at: DateTime, } -/// 单一货币的账号成本聚合 -#[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, - pub output_tokens: Option, - pub cached_tokens: Option, - pub cache_write_tokens: Option, - pub reasoning_tokens: Option, - pub image_input_tokens: Option, - pub image_output_tokens: Option, - pub image_request_count: u64, - pub image_request_failed_count: u64, - pub total_tokens: Option, - pub cost_coverage: super::observability::CostCoverage, - pub costs: Vec, - pub last_used_at: DateTime, -} - -/// 账号在一个小时窗口内的请求数 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AccountRequestBucket { - pub bucket_start: DateTime, - 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, } -/// 统一账号目录的全局状态计数,不受当前筛选和分页影响 -/// -/// 计数与 [`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 { diff --git a/backend/crates/gateway-admin/src/model/client_keys.rs b/backend/crates/gateway-admin/src/model/client_keys.rs index 8c11f1ef6..f0e22ead6 100644 --- a/backend/crates/gateway-admin/src/model/client_keys.rs +++ b/backend/crates/gateway-admin/src/model/client_keys.rs @@ -20,6 +20,20 @@ pub type ProviderRequestProfileOverrides = pub type ProviderRequestProfileOverrideUpdates = BTreeMap>; +/// Client Key 关联分组的数量与唯一性约束 +pub fn validate_group_ids(group_ids: &[AccountGroupId]) -> Result<(), AdminModelError> { + if group_ids.len() > 1000 + || group_ids + .iter() + .collect::>() + .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); diff --git a/backend/crates/gateway-admin/src/model/key_usage.rs b/backend/crates/gateway-admin/src/model/key_usage.rs index 7d779ce11..923739d14 100644 --- a/backend/crates/gateway-admin/src/model/key_usage.rs +++ b/backend/crates/gateway-admin/src/model/key_usage.rs @@ -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 { diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index ca4f0b8ce..c0a7b01ac 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -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")] diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 0012f8d47..1b7ae6643 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -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 { + 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 { - 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, } -/// Dashboard 中账号的单小时请求数 +/// 账号在单小时窗口内的请求数 #[derive(Debug, Clone, PartialEq, Eq)] -pub struct DashboardAccountRequestBucket { +pub struct AccountRequestBucket { pub bucket_start: DateTime, pub request_count: u64, } @@ -530,14 +552,14 @@ pub struct DashboardAccountUsage { pub cost_coverage: CostCoverage, pub costs: Vec, pub last_used_at: Option>, - pub request_buckets: Vec, + pub request_buckets: Vec, /// Provider 已持久化额度窗口投影出的代表性已用比例 /// /// `None` 表示上游未提供可比较的百分比,不应伪造为零 pub quota_used_percent: Option, /// Provider 返回的代表窗口;缺失时保留未知语义 pub quota_window: Option, - pub models: Vec, + pub models: Vec, } /// 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, diff --git a/backend/crates/gateway-admin/src/model/retention.rs b/backend/crates/gateway-admin/src/model/retention.rs index 6f20d08f7..d89664260 100644 --- a/backend/crates/gateway-admin/src/model/retention.rs +++ b/backend/crates/gateway-admin/src/model/retention.rs @@ -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 { - 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 天,事件与审计保留期必须为正数", )); diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 00e33c6d4..5a5e7d6df 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -11,6 +11,26 @@ use super::Revision; /// 客户端模型到上游模型的全局精确映射 pub type ModelMappings = BTreeMap; +/// 全局模型映射的数量与文本约束;调用方保留各自的键值表示 +pub fn validate_model_mappings<'a>( + mappings: impl ExactSizeIterator, +) -> 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; @@ -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, pub min_codex_cli_version: Option, 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, +} + +/// 完整运行设置事实 +#[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, } @@ -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, - pub min_codex_cli_version: Option, - 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, - pub account_auto_freeze_adaptive_concurrency: bool, - pub account_warmup_enabled: bool, - pub account_warmup_schedule_time: String, - pub account_warmup_model: Option, } /// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏 @@ -180,39 +179,63 @@ impl From 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(()) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/account_groups.rs b/backend/crates/gateway-admin/src/use_case/account_groups.rs index 8fd38f238..d6c8b22ef 100644 --- a/backend/crates/gateway-admin/src/use_case/account_groups.rs +++ b/backend/crates/gateway-admin/src/use_case/account_groups.rs @@ -135,6 +135,11 @@ impl AccountGroupService for DefaultAccountGroupService { context: &MutationContext, command: CreateAccountGroup, ) -> Result { + 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 { + 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 } diff --git a/backend/crates/gateway-admin/src/use_case/client_keys.rs b/backend/crates/gateway-admin/src/use_case/client_keys.rs index 7bbc7099c..ac28696bb 100644 --- a/backend/crates/gateway-admin/src/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/src/use_case/client_keys.rs @@ -157,6 +157,8 @@ impl ClientKeyService for DefaultClientKeyService { context: &MutationContext, command: CreateClientKey, ) -> Result { + 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 { + 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 diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index ccaa07895..9d362463e 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -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| { diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index 82d0257c8..edd181c4a 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -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] diff --git a/backend/crates/gateway-admin/tests/model/account_groups.rs b/backend/crates/gateway-admin/tests/model/account_groups.rs new file mode 100644 index 000000000..227d82455 --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/account_groups.rs @@ -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()); +} diff --git a/backend/crates/gateway-admin/tests/model/client_keys.rs b/backend/crates/gateway-admin/tests/model/client_keys.rs new file mode 100644 index 000000000..1a378327f --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/client_keys.rs @@ -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()); +} diff --git a/backend/crates/gateway-admin/tests/model/mod.rs b/backend/crates/gateway-admin/tests/model/mod.rs index 877f0dbe7..cadf813a1 100644 --- a/backend/crates/gateway-admin/tests/model/mod.rs +++ b/backend/crates/gateway-admin/tests/model/mod.rs @@ -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; diff --git a/backend/crates/gateway-admin/tests/model/observability.rs b/backend/crates/gateway-admin/tests/model/observability.rs new file mode 100644 index 000000000..418261bcf --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/observability.rs @@ -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::().unwrap().as_str(), expected); + } + for input in [ + "", + "-1", + "+1", + "NaN", + "inf", + "1e3", + ".1", + "1.", + "1.2.3", + "10000000000", + "0.00000000001", + "1", + ] { + assert!(input.parse::().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()); + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/account_groups.rs b/backend/crates/gateway-admin/tests/use_case/account_groups.rs index 920896597..cb5298910 100644 --- a/backend/crates/gateway-admin/tests/use_case/account_groups.rs +++ b/backend/crates/gateway-admin/tests/use_case/account_groups.rs @@ -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); +} diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 1f1cc3404..c869a687b 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -1110,40 +1110,42 @@ impl SettingsStore for StaticSettingsStore { async fn load_runtime_settings(&self) -> AdminStoreResult { 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, + }, }) } diff --git a/backend/crates/gateway-admin/tests/use_case/client_keys.rs b/backend/crates/gateway-admin/tests/use_case/client_keys.rs index 51c3b6f44..517656095 100644 --- a/backend/crates/gateway-admin/tests/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/tests/use_case/client_keys.rs @@ -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()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index 0c3ca290c..0d3371646 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -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, + }, } } diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 0b85c32a0..907a08372 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -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 { 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) -> 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"), } } diff --git a/backend/crates/gateway-admin/tests/use_case/service_contract.rs b/backend/crates/gateway-admin/tests/use_case/service_contract.rs index 073997dd3..3d34cf4a5 100644 --- a/backend/crates/gateway-admin/tests/use_case/service_contract.rs +++ b/backend/crates/gateway-admin/tests/use_case/service_contract.rs @@ -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::>() + } 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 { diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index 1f6af84dd..e7dbbdc6c 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -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 diff --git a/backend/crates/gateway-api/src/admin/account_groups.rs b/backend/crates/gateway-api/src/admin/account_groups.rs index 1e7da1c1e..8c60b4f21 100644 --- a/backend/crates/gateway-api/src/admin/account_groups.rs +++ b/backend/crates/gateway-api/src/admin/account_groups.rs @@ -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 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 { } 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 { diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 6e3635aee..3297dfb43 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -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, diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 0faa2058f..3bb6fedf1 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -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)] diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index dd8f6ba62..03909274b 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -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, routing_scope: &'static str, - groups: Vec, + groups: Vec, provider_kinds: Vec, prefix: String, enabled: bool, @@ -365,15 +366,6 @@ pub struct ClientKeyView { last_used_at_full_display: Option, } -#[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) -> Result, WireValidationError> { - if values.len() > 1000 - || values - .iter() - .collect::>() - .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::, _>>()?; + gateway_admin::model::client_keys::validate_group_ids(&groups) + .map_err(|_| WireValidationError::new("groupIds"))?; + Ok(groups) } fn validate_optional_text( diff --git a/backend/crates/gateway-api/src/admin/observability/mod.rs b/backend/crates/gateway-api/src/admin/observability/mod.rs index 2cb6cf082..29e953815 100644 --- a/backend/crates/gateway-api/src/admin/observability/mod.rs +++ b/backend/crates/gateway-api/src/admin/observability/mod.rs @@ -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; diff --git a/backend/crates/gateway-api/src/admin/observability/query.rs b/backend/crates/gateway-api/src/admin/observability/query.rs index cd0ce9a9b..b5d836844 100644 --- a/backend/crates/gateway-api/src/admin/observability/query.rs +++ b/backend/crates/gateway-api/src/admin/observability/query.rs @@ -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)) diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index 81e9cdab9..c3948decd 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -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, }) diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index 384b60613..b6e706a18 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -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>>; -/// 运行配置投影与设置页字段的聚合响应 -#[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>, - /// 固定兼容字段;值始终从 provider_request_profiles 派生 - pub xai_client_profile: Option>, +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, pub min_codex_cli_version: Option, 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, +} + +/// 运行配置投影与设置页字段的聚合响应 +#[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>, + /// 固定兼容字段;值始终从 provider_request_profiles 派生 + pub xai_client_profile: Option>, + pub model_mappings: ModelMappings, + pub rotation_strategy: String, pub updated_at: DateTime, 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>, - 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, - pub min_codex_cli_version: Option, - 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, - pub account_auto_freeze_adaptive_concurrency: bool, - pub account_warmup_enabled: bool, - pub account_warmup_schedule_time: String, - pub account_warmup_model: Option, } 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 { - 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 { + 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 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", + }) +} diff --git a/backend/crates/gateway-api/src/key_usage/query.rs b/backend/crates/gateway-api/src/key_usage/query.rs index f48022ee0..83d68ba01 100644 --- a/backend/crates/gateway-api/src/key_usage/query.rs +++ b/backend/crates/gateway-api/src/key_usage/query.rs @@ -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 { 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, }) } } diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index ed4cb7421..cbfc24824 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -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, + }, } } diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index 249911f63..e4abfe63d 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -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::, 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 = 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::( + &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::(&serde_json::to_vec(¤t).unwrap()).unwrap(), + current + ); + let mut record = serde_json::to_value(¤t).unwrap(); + assert!(record.get("values").is_none()); + assert_eq!( + serde_json::from_value::(record.clone()).unwrap(), + current + ); + record["unexpected_setting"] = json!(true); + assert!(serde_json::from_value::(record).is_err()); + + let command = ReplaceRuntimeSettings::from(current); + assert_eq!( + serde_json::from_slice::(&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::(wire.clone()).unwrap(), + command + ); + wire["unexpected_setting"] = json!(true); + assert!(serde_json::from_value::(wire).is_err()); +} + #[test] fn settings_request_should_reject_unknown_revision_field() { let mut body = update_body(); diff --git a/backend/crates/gateway-core/src/account/smart_scheduling.rs b/backend/crates/gateway-core/src/account/smart_scheduling.rs index ada5e080c..c87206075 100644 --- a/backend/crates/gateway-core/src/account/smart_scheduling.rs +++ b/backend/crates/gateway-core/src/account/smart_scheduling.rs @@ -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>(deserializer: D) -> Result { + // flatten 会缓冲字段,arbitrary_precision 的小数需先恢复为 JSON Number + // 随后仍由同一类型检查完整字段、数值范围和十分位精度 + let value = serde_json::Value::deserialize(deserializer)?; + let values = serde_json::from_value::(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 { diff --git a/backend/crates/gateway-core/src/provider_ports.rs b/backend/crates/gateway-core/src/provider_ports.rs index 50f063af2..b9df9ffcb 100644 --- a/backend/crates/gateway-core/src/provider_ports.rs +++ b/backend/crates/gateway-core/src/provider_ports.rs @@ -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, adaptive_concurrency: bool, ) -> Result { - 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, ) -> Result { - 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()) + }) +} diff --git a/backend/crates/gateway-core/src/settings/compiled.rs b/backend/crates/gateway-core/src/settings/compiled.rs index 7aecf512b..92be6a5de 100644 --- a/backend/crates/gateway-core/src/settings/compiled.rs +++ b/backend/crates/gateway-core/src/settings/compiled.rs @@ -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 { - 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( diff --git a/backend/crates/gateway-core/src/settings/mod.rs b/backend/crates/gateway-core/src/settings/mod.rs index dacd9fd68..b521c09c0 100644 --- a/backend/crates/gateway-core/src/settings/mod.rs +++ b/backend/crates/gateway-core/src/settings/mod.rs @@ -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)] diff --git a/backend/crates/gateway-core/src/settings/values.rs b/backend/crates/gateway-core/src/settings/values.rs index b9aa05158..fe5753b3f 100644 --- a/backend/crates/gateway-core/src/settings/values.rs +++ b/backend/crates/gateway-core/src/settings/values.rs @@ -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 { + 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 { + 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")?, + )) +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/service/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/service/mod.rs index 1606e98ea..a39bb461f 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/service/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/service/mod.rs @@ -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 ); diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs index ff52ba1a9..42f123220 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs @@ -18,11 +18,8 @@ pub type PricingOverrides = BTreeMap, pub min_codex_cli_version: Option, 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, + 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, pub min_codex_cli_version: Option, 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, + /// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新 + 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 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 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 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, } } } diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index a44b8d0a0..140127fed 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -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, }) } diff --git a/backend/crates/gateway-store/src/lib.rs b/backend/crates/gateway-store/src/lib.rs index 4139823bf..cdcfad762 100644 --- a/backend/crates/gateway-store/src/lib.rs +++ b/backend/crates/gateway-store/src/lib.rs @@ -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::{ diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 04022aa5b..808cc9aa6 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -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<()> { diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index fbe4baeb9..a7a47f072 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -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::>().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::, _>>() + .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<()> { diff --git a/backend/crates/gateway-store/src/postgres/execution.rs b/backend/crates/gateway-store/src/postgres/execution.rs index 9ea7c305f..dbc0a5f36 100644 --- a/backend/crates/gateway-store/src/postgres/execution.rs +++ b/backend/crates/gateway-store/src/postgres/execution.rs @@ -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, - pub output_tokens: Option, - pub cached_tokens: Option, - pub cache_write_tokens: Option, - pub reasoning_tokens: Option, - pub image_input_tokens: Option, - pub image_output_tokens: Option, - pub total_tokens: Option, -} +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, - pub connect_ms: Option, - pub headers_ms: Option, - pub first_event_ms: Option, - pub first_reasoning_ms: Option, - pub first_text_ms: Option, - pub first_token_ms: Option, - pub provider_processing_ms: Option, - pub latency_ms: Option, -} - -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::() + .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::::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, diff --git a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs index e20ec571d..ce0143655 100644 --- a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs @@ -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> { - 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> { - 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) } } diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index ce36cb383..c8e39d500 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -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 { 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 { 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::>()?, + 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::>()?, - recent_requests: recent_requests - .into_iter() - .map(admin_usage_list_record) - .collect::>()?, - }) -} - -pub(crate) fn admin_dashboard_totals( - totals: DashboardTotals, -) -> AdminStoreResult { - 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 { - 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 { - 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 { - 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, -) -> AdminStoreResult> { - 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::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, -) -> AdminStoreResult> { - 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 { - 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::>()?, - }) + .map(admin_account_model_usage) + .collect(), + } } -pub(crate) fn admin_dashboard_account_model_usage( +pub(crate) fn admin_account_model_usage( usage: ProviderAccountModelUsageObservation, -) -> AdminStoreResult { - 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 { - 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 { - 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 { - Ok(admin_observability::UsagePage { - items: page - .items - .into_iter() - .map(admin_usage_list_record) - .collect::>()?, - current_page: page.current_page, - page_size: page.page_size, - total: page.total, - }) -} - fn restore_billing_snapshot( billing: Option, snapshot: Option<&serde_json::Value>, @@ -437,257 +290,27 @@ fn restore_billing_snapshot( billing } -pub(crate) fn admin_usage_list_record( - record: UsageListRecord, -) -> AdminStoreResult { - 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 { - 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 { +pub(crate) fn request_outcome(outcome: &str) -> StoreResult { 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 { - 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::>()?, - }) -} - -pub(crate) fn admin_usage_attempt( - attempt: UsageAttemptObservation, -) -> AdminStoreResult { - 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 { - 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 { - 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::>()?, - }) + .collect(), + } } fn admin_diagnostic_observation( observation: DiagnosticObservation, -) -> AdminStoreResult { - 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 { - 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 { + let cost_source: String = get(row, "cost_source")?; + let cost_amount = optional_decimal(row, "cost_amount")?; + let cost_currency: Option = 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 { + let cost_source: String = get(row, "cost_source")?; + let cost_amount = optional_decimal(row, "cost_amount")?; + let cost_currency: Option = 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::(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 StoreResult StoreResult { Ok(CurrencyCostTotal { currency: get(row, "cost_currency")?, - amount: DecimalAmount::from_str(&get::(row, "amount")?)?, + amount: parse_decimal_amount(&get::(row, "amount")?)?, }) } @@ -1036,7 +610,7 @@ pub(crate) fn optional_decimal( column: &'static str, ) -> StoreResult> { get::>(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::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> { + 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 = get(row, "billing_snapshot_json")?; + Ok(restore_billing_snapshot(billing, snapshot.as_ref())) +} diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs index 974291522..62987e5ca 100644 --- a/backend/crates/gateway-store/src/postgres/observability/model.rs +++ b/backend/crates/gateway-store/src/postgres/observability/model.rs @@ -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 { - 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, - pub request_id: Option, - pub provider_account_ref: Option, - pub provider_kind: Option, - pub operation: Option, - pub model: Option, - pub transport: Option, - pub attempt_index: Option, - pub response_id: Option, - pub upstream_request_id: Option, - pub status_code: Option, - pub search: Option, -} +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 { - 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, - pub p95_ms: Option, - pub p99_ms: Option, -} +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, - 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, -} +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, - pub billing_snapshot_json: Option, - pub id: String, - pub endpoint: String, - pub client_transport: String, - pub requested_model_id: Option, - pub provider_kind: Option, - pub provider_account_ref: Option, - pub provider_account_name: Option, - pub provider_account_email: Option, - pub provider_account_notes: Option, - pub provider_account_plan_type: Option, - pub provider_account_authentication_kind: Option, - pub upstream_model_id: Option, - pub upstream_transport: Option, - pub upstream_response_model: Option, - pub service_tier: Option, - pub input_tokens: Option, - pub output_tokens: Option, - pub cached_tokens: Option, - pub cache_write_tokens: Option, - pub reasoning_tokens: Option, - pub image_input_tokens: Option, - pub image_output_tokens: Option, - pub total_tokens: Option, - pub cost_source: String, - pub cost_amount: Option, - pub cost_currency: Option, - pub transport_decision_wait_ms: Option, - pub connect_ms: Option, - pub headers_ms: Option, - pub first_event_ms: Option, - pub first_reasoning_ms: Option, - pub first_text_ms: Option, - pub first_token_ms: Option, - pub provider_processing_ms: Option, - pub latency_ms: Option, - pub admission_decision_ms: Option, - pub account_selection_wait_ms: Option, - pub capacity_used_slots: Option, - pub capacity_total_slots: Option, - pub client_ip: Option, - pub user_agent: Option, - pub reasoning_effort: Option, - pub reasoning_preset: Option, - pub subagent_kind: Option, - pub compact: bool, - pub started_at: DateTime, -} +pub use gateway_admin::model::observability::UsageListRecord; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UsageRecord { - pub billing_snapshot_json: Option, - pub id: String, - pub client_api_key_ref: String, - pub config_revision: u64, - pub routing_scope: String, - pub routing_group_refs: Vec, - pub routing_group_names_snapshot: Vec, - pub protocol: String, - pub operation: String, - pub endpoint: String, - pub client_transport: String, - pub requested_model_id: Option, - pub provider_kind: Option, - pub provider_account_ref: Option, - pub provider_account_name: Option, - pub provider_account_email: Option, - pub provider_account_authentication_kind: Option, - pub upstream_model_id: Option, - pub upstream_transport: Option, - pub http_version: Option, - pub websocket_pool: Option, - pub upstream_response_model: Option, - pub service_tier: Option, - pub provider_metadata_json: Option, - pub attempt_count: u32, - pub upstream_send_state: String, - pub downstream_committed_at: Option>, - pub outcome: String, - pub client_status_code: Option, - pub upstream_status_code: Option, - pub client_response_id: Option, - pub upstream_request_id: Option, - pub upstream_response_id: Option, - pub error_kind: Option, - pub provider_error_code: Option, - pub error_message: Option, - pub retry_after_ms: Option, - pub input_tokens: Option, - pub output_tokens: Option, - pub cached_tokens: Option, - pub cache_write_tokens: Option, - pub reasoning_tokens: Option, - pub image_input_tokens: Option, - pub image_output_tokens: Option, - pub total_tokens: Option, - pub cost_source: String, - pub cost_amount: Option, - pub cost_currency: Option, - pub transport_decision_wait_ms: Option, - pub connect_ms: Option, - pub headers_ms: Option, - pub first_event_ms: Option, - pub first_reasoning_ms: Option, - pub first_text_ms: Option, - pub first_token_ms: Option, - pub provider_processing_ms: Option, - pub latency_ms: Option, - pub admission_decision_ms: Option, - pub account_selection_wait_ms: Option, - pub capacity_used_slots: Option, - pub capacity_total_slots: Option, - pub client_ip: Option, - pub user_agent: Option, - pub reasoning_effort: Option, - pub reasoning_preset: Option, - pub request_kind: Option, - pub subagent_kind: Option, - pub compact: bool, - pub image_generation_requested: bool, - pub image_generation_succeeded: Option, - pub started_at: DateTime, - pub deadline_at: DateTime, - pub completed_at: Option>, -} +pub use gateway_admin::model::observability::UsageRecord; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UsageRecordPage { - pub items: Vec, - 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, - pub provider_account_ref: Option, - pub provider_account_name: Option, - pub provider_account_email: Option, - pub provider_account_authentication_kind: Option, - pub upstream_model_id: Option, - pub upstream_transport: Option, - pub upstream_send_state: Option, - pub outcome: String, - pub downstream_committed: bool, - pub status_code: Option, - pub provider_error_code: Option, - pub failure_kind: Option, - pub retry_after_ms: Option, - pub upstream_request_id: Option, - pub latency_ms: Option, - pub message: Option, - pub input_tokens: Option, - pub output_tokens: Option, - pub cached_tokens: Option, - pub cache_write_tokens: Option, - pub reasoning_tokens: Option, - pub total_tokens: Option, - pub cost_source: Option, - pub cost_amount: Option, - pub cost_currency: Option, - pub occurred_at: DateTime, -} +pub use gateway_admin::model::observability::UsageAttempt as UsageAttemptObservation; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UsageRecordDetail { - pub trace: Option, - pub related_requests: Vec, - pub request: UsageRecord, - pub attempts: Vec, -} +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, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpsErrorRecord { - pub client_api_key_name: Option, - pub source: String, - pub event_id: String, - pub request_id: Option, - pub attempt_index: Option, - pub client_api_key_ref: Option, - pub component: String, - pub operation: String, - pub protocol: Option, - pub client_transport: Option, - pub requested_model_id: Option, - pub service_tier: Option, - pub endpoint: Option, - pub provider_kind: Option, - pub provider_account_ref: Option, - pub provider_account_name: Option, - pub provider_account_email: Option, - pub provider_account_plan_type: Option, - pub provider_account_authentication_kind: Option, - pub upstream_model_id: Option, - pub upstream_transport: Option, - pub failure_kind: String, - pub upstream_send_state: Option, - pub client_status_code: Option, - pub upstream_status_code: Option, - pub provider_error_code: Option, - pub client_response_id: Option, - pub upstream_request_id: Option, - pub latency_ms: Option, - pub message: String, - pub error_details: Option, - pub client_ip: Option, - pub user_agent: Option, - pub reasoning_effort: Option, - pub reasoning_preset: Option, - pub request_kind: Option, - pub subagent_kind: Option, - pub compact: Option, - pub continuation_affinity_hash: Option, - pub continuation_previous_response_id_hash: Option, - pub continuation_unavailable_reason: Option, - pub upstream_connection_id: Option, - pub upstream_connection_exit_reason: Option, - pub upstream_connection_age_ms: Option, - pub upstream_connection_idle_ms: Option, - pub recovery_request_id: Option, - pub recovered_at: Option>, - pub recovery_attempt_count: u32, - pub recovery_retry_delay_ms: Option, - pub recovery_total_latency_ms: Option, - pub occurred_at: DateTime, - 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, - 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 { diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs b/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs index f67e2a6a2..3a49aea17 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs @@ -616,7 +616,10 @@ pub(crate) fn optional_percentile( ) -> StoreResult> { row.try_get::, _>(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() } diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs index 43770afac..26a8c9b03 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs @@ -76,7 +76,7 @@ pub(crate) async fn list_ops_errors( pool: &PgPool, query: OpsErrorQuery, ) -> StoreResult { - 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::::new("select * from ("); diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index 3fa1402bd..42a8561aa 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -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")?, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index 36cf5a6d5..7d693d42d 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -460,11 +460,12 @@ impl AccountStore for PgAdminAccountStore { ) -> AdminStoreResult> { 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( diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs index 2b05ca61c..5e2eb4987 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs @@ -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 { - 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::>>()?, - }) + .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 { - 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, -) -> AdminStoreResult> { - 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() -} diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs index 89bb4317a..fbc6918d3 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs @@ -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, }; diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index e1ebd673f..d4114d7b9 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -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, pub config_revision: Revision, pub admin_api_key: Option, - 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, - pub min_codex_desktop_version: Option, - pub min_codex_cli_version: Option, - 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, - pub account_auto_freeze_adaptive_concurrency: bool, - pub account_warmup_enabled: bool, - pub account_warmup_schedule_time: String, - pub account_warmup_model: Option, pub updated_at: DateTime, } @@ -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, >, - 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, - pub min_codex_desktop_version: Option, - pub min_codex_cli_version: Option, - 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, - pub account_auto_freeze_adaptive_concurrency: bool, - pub account_warmup_enabled: bool, - pub account_warmup_schedule_time: String, - pub account_warmup_model: Option, } 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 { 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 ProviderStoreError { fn provider_conflict(operation: &'static str) -> ProviderStoreError { ProviderStoreError::new(ProviderStoreErrorKind::Conflict, operation) } - -fn valid_model_mappings(mappings: &BTreeMap) -> 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()) - }) -} diff --git a/backend/crates/gateway-store/src/postgres/snapshot.rs b/backend/crates/gateway-store/src/postgres/snapshot.rs index f7a4e548e..b282397eb 100644 --- a/backend/crates/gateway-store/src/postgres/snapshot.rs +++ b/backend/crates/gateway-store/src/postgres/snapshot.rs @@ -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, - 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, - pub min_codex_desktop_version: Option, - pub min_codex_cli_version: Option, -} - #[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, pub account_groups: Vec, pub provider_accounts: Vec, @@ -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, pricing_overrides_json: sqlx::types::Json, 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, @@ -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)?, + ), )) } diff --git a/backend/crates/gateway-store/src/value.rs b/backend/crates/gateway-store/src/value.rs index 4b794ddc3..03ff2816f 100644 --- a/backend/crates/gateway-store/src/value.rs +++ b/backend/crates/gateway-store/src/value.rs @@ -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 { - 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 diff --git a/backend/crates/gateway-store/tests/postgres/execution.rs b/backend/crates/gateway-store/tests/postgres/execution.rs index 3f96fb180..a8cc13f25 100644 --- a/backend/crates/gateway-store/tests/postgres/execution.rs +++ b/backend/crates/gateway-store/tests/postgres/execution.rs @@ -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; +} diff --git a/backend/crates/gateway-store/tests/postgres/execution_buffer.rs b/backend/crates/gateway-store/tests/postgres/execution_buffer.rs index d42496cfd..3ed172f79 100644 --- a/backend/crates/gateway-store/tests/postgres/execution_buffer.rs +++ b/backend/crates/gateway-store/tests/postgres/execution_buffer.rs @@ -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") diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index 51bc5190f..c82f3830a 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -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"); diff --git a/backend/crates/gateway-store/tests/postgres/pricing.rs b/backend/crates/gateway-store/tests/postgres/pricing.rs index 541d9b4d7..af20fc386 100644 --- a/backend/crates/gateway-store/tests/postgres/pricing.rs +++ b/backend/crates/gateway-store/tests/postgres/pricing.rs @@ -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(¤t.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 diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index 4b94dea27..ff791cf94 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -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::( + 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 ); diff --git a/docs/architecture.md b/docs/architecture.md index 634aee431..038ea87c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 周期对账才是正确性基础