Compare commits

...
12 Commits
Author SHA1 Message Date
ZyyandCodex 9c10676764 chore(release): v3.18.3
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 22:20:06 +08:00
ZyyandCodex caa6c8ffc8 refactor(plugin): keep external quota cycles out of host ledger (#332)
Restore the pre-window host implementation and document plugin-owned policy and presentation. Keep native reset RPC coverage and run plugin integration tests with the standard isolated test services.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 21:47:50 +08:00
9d651856be fix(outbound): avoid duplicate Host headers on HTTP/2 (#337)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 19:26:58 +08:00
ZyyandCodex d10b8890e0 fix(admin): improve quota forecast and subscription display
Move the quota forecast entry after reset credits and propagate account updates from the quota panel. Include each error account current subscription in troubleshooting and unify subscription labels.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 18:45:08 +08:00
ZyyandCodex d97c6e064b fix(keys): restart budget windows on use after reset (#335)
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 17:54:47 +08:00
ZyyandCodex f85801edb3 fix(usage): balance diagnostic column widths
Size diagnostic names by dimension and share available width across metric columns. Keep API key names compact.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 04:33:21 +08:00
ZyyandCodex e30aad4755 chore(release): v3.18.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:41:55 +08:00
ZyyandCodex 945f7912ac chore(deps): sync UI v0.3.1 and plugin references
Update the admin UI dependency and lockfile, and align the UI and plugin submodules with the released modal fix.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:38:10 +08:00
ZyyandCodex 19038f264a fix(admin): correct usage diagnostics and refine management views
Use all matching requests for diagnostic shares and count retried requests once. Show current account plans in diagnostics and request records with compact, aligned columns.

Simplify quota forecasts and remove unused remaining-capacity response fields. Open system updates immediately with the existing loading skeleton and clip masked key prefixes without an ellipsis.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:28:58 +08:00
ZyyandCodex 71c3b03878 fix(openai): add built-in GPT-6.1 Sol pricing
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:27:01 +08:00
ZyyandCodex 70d1557b55 chore(release): v3.18.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 01:36:39 +08:00
ZyyandCodex b4d11694ae fix(update): confirm incompatible plugin shutdown before restart
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 01:36:32 +08:00
107 changed files with 2451 additions and 479 deletions
+2
View File
@@ -177,6 +177,8 @@ jobs:
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
host = JSON.parse(File.read(host_path))
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
host = host.merge("schema_version" => 1, "permissions" => [])
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
manifest = {
"schema_version" => 1,
+1
View File
@@ -422,6 +422,7 @@ async fn initialize_inner(
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
store.plugins(),
plugin_inspector.clone(),
snapshot.clone(),
));
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
@@ -558,6 +558,9 @@ pub struct UsageListRecord {
pub provider_account_email: Option<String>,
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
pub provider_account_notes: Option<String>,
/// 账号当前套餐,不属于请求历史快照。
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -757,11 +760,20 @@ pub struct UsageSummary {
pub average_latency_ms: Option<u64>,
}
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagnosticsObservation {
pub total_request_count: u64,
pub items: Vec<DiagnosticObservation>,
}
/// 单个诊断维度值的聚合结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub account_provider_kind: Option<String>,
pub account_plan_type: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
@@ -772,6 +784,7 @@ pub struct DiagnosticObservation {
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -796,6 +809,8 @@ pub struct OpsError {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -1142,6 +1157,8 @@ pub struct UsageInsights {
pub struct DiagnosticsItem {
pub key: String,
pub name: String,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -111,6 +111,7 @@ pub struct PluginInstanceRuntime {
pub struct PluginInstanceView {
pub instance: PluginInstance,
pub configuration_required: bool,
pub compatibility_warning: Option<String>,
pub running: bool,
pub published_revision: Option<u64>,
pub runtime: PluginInstanceRuntime,
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
pub incomplete_tokens: bool,
pub estimated_tokens: Option<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
pub remaining_tokens: Option<u64>,
pub remaining_usd: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -81,8 +78,6 @@ pub fn account_quota_forecasts(
incomplete_tokens: false,
estimated_tokens: None,
estimated_usd: None,
remaining_tokens: None,
remaining_usd: None,
};
if let Some((window, source_period)) = selected {
forecast.project(
@@ -191,14 +186,13 @@ impl AccountQuotaForecast {
let remaining_tokens = Some(sample.usage.tokens)
.filter(|tokens| *tokens > 0)
.and_then(|value| estimate(value as f64, remaining_factor));
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
self.estimated_tokens = remaining_tokens
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
self.remaining_usd = Some(&sample.usage)
let remaining_usd = Some(&sample.usage)
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
.and_then(|usage| estimate(usage.usd, remaining_factor));
self.estimated_usd = usd
.zip(self.remaining_usd)
.zip(remaining_usd)
.and_then(|(used, remaining)| estimate(used + remaining, factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
@@ -162,3 +162,21 @@ impl SystemOperationAccepted {
}
}
}
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SystemRestartPlan {
pub target_version: Option<String>,
pub release_manifest_sha256: Option<String>,
pub config_revision: u64,
pub incompatible_plugins: Vec<SystemIncompatiblePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SystemIncompatiblePlugin {
pub instance_id: String,
pub name: String,
pub reason: String,
}
@@ -219,6 +219,19 @@ pub trait PluginStore: Send + Sync {
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态。
async fn disable_instances(
&self,
_ids: &[String],
_expected_revision: Revision,
_context: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::store::AdminStoreError::new(
super::store::AdminStoreErrorKind::Unavailable,
"plugin",
"atomic plugin disable is unavailable",
))
}
async fn save_instance(
&self,
instance: PluginInstance,
@@ -27,7 +27,7 @@ use crate::model::{
UpdateClientKey, UpdateClientKeyBudgetLimits,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
@@ -444,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
) -> AdminStoreResult<DiagnosticsObservation>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
}
@@ -77,10 +77,27 @@ pub trait SystemUpdatePreflight: Send + Sync {
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError>;
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。
async fn validate_rollback(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
self.validate(candidate).await
}
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
}
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。
#[async_trait]
pub trait SystemRestartPreflight: Send + Sync {
async fn prepare(
&self,
candidate: Option<SystemUpdateCandidate>,
) -> Result<(), SystemOperationError>;
}
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
#[async_trait]
pub trait SystemOperations: Send + Sync {
@@ -108,5 +125,15 @@ pub trait SystemOperations: Send + Sync {
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。
async fn restart_candidate(
&self,
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
Ok(None)
}
async fn restart(
&self,
preflight: Arc<dyn SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
}
@@ -161,7 +161,7 @@ impl DefaultObservabilityService {
self.store.dashboard_runtime_slots(observed_at),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
self.enrich_list_billing(&mut observation.recent_requests);
self.enrich_list_records(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
@@ -268,7 +268,7 @@ impl ObservabilityService for DefaultObservabilityService {
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_list_billing(&mut page.items);
self.enrich_list_records(&mut page.items);
Ok(page)
}
@@ -334,29 +334,37 @@ impl ObservabilityService for DefaultObservabilityService {
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> Result<DiagnosticsResult, AdminError> {
let items = self
let observation = self
.store
.usage_diagnostics(range, filter, dimension)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
let items = items
let items = observation
.items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
DiagnosticsItem {
key: item.key,
name: item.name,
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|provider| {
self.providers
.plan_type_display(provider, item.account_plan_type.as_deref())
},
),
account_plan_type: item.account_plan_type,
request_count: item.request_count,
success_count: item.success_count,
error_count: item.failure_count,
error_rate,
request_share: rate_or_zero(item.request_count, total_requests),
request_share: rate_or_zero(
item.request_count,
observation.total_request_count,
),
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
@@ -374,10 +382,19 @@ impl ObservabilityService for DefaultObservabilityService {
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
self.store
let mut page = self
.store
.list_ops_errors(query)
.await
.map_err(|error| map_store_error(error, "operations errors"))
.map_err(|error| map_store_error(error, "operations errors"))?;
for error in &mut page.items {
error.provider_account_plan_type_display =
error.provider_kind.as_deref().and_then(|provider| {
self.providers
.plan_type_display(provider, error.provider_account_plan_type.as_deref())
});
}
Ok(page)
}
}
@@ -772,10 +789,15 @@ impl DefaultObservabilityService {
}
}
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
for record in records {
record.provider_account_plan_type_display =
record.provider_kind.as_deref().and_then(|provider| {
self.providers
.plan_type_display(provider, record.provider_account_plan_type.as_deref())
});
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
continue;
};
@@ -1,6 +1,14 @@
use std::{collections::BTreeMap, sync::Arc};
use super::publish_committed;
use crate::model::{
MutationContext,
system::{SystemIncompatiblePlugin, SystemRestartPlan},
};
use crate::ports::system::SystemRestartPreflight;
use async_trait::async_trait;
use gateway_core::runtime::SnapshotControl;
use sha2::{Digest as _, Sha256};
use crate::{
model::{AdminErrorKind, Revision},
@@ -14,20 +22,26 @@ use crate::{
},
};
use super::plugins::official::update_compatibility;
use super::plugins::official::{update_compatibility, validate_update_release};
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。
pub(crate) struct PluginSystemUpdatePreflight {
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
snapshots: Arc<dyn SnapshotControl>,
}
impl PluginSystemUpdatePreflight {
pub(crate) fn new(
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
snapshots: Arc<dyn SnapshotControl>,
) -> Self {
Self { store, inspector }
Self {
store,
inspector,
snapshots,
}
}
}
@@ -37,59 +51,28 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
let target = semver::Version::parse(candidate.target_version.trim_start_matches('v'))
.map_err(|_| invalid("目标网关版本不合法"))?;
let compatibility = update_compatibility(
candidate.release_manifest.as_ref(),
candidate.target_version.trim_start_matches('v'),
)
.map_err(|_| invalid("目标发行物缺少有效的插件兼容声明"))?;
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
let mut inspected = BTreeMap::new();
for instance in snapshot
.instances
.iter()
.filter(|instance| instance.enabled)
{
let requirements = if let Some(requirements) = inspected.get(&instance.artifact_sha256)
{
requirements
} else {
let artifact = self
.store
.load_artifact(&instance.artifact_sha256)
.await
.map_err(map_store_error)?;
let requirements = self
.inspector
.compatibility(artifact.archive, instance.artifact_sha256.clone())
.await
.map_err(map_inspection_error)?;
inspected.insert(instance.artifact_sha256.clone(), requirements);
inspected
.get(&instance.artifact_sha256)
.ok_or_else(|| internal("插件兼容性检查结果不可用"))?
};
let host_versions = semver::VersionReq::parse(&requirements.host_version)
.map_err(|_| incompatible(&instance.id))?;
if !host_versions.matches(&target)
|| !compatibility
.manifest_schema_versions
.contains(&requirements.manifest_schema_version)
|| !compatibility
.protocol_versions
.contains(&requirements.protocol_version)
|| requirements
.capabilities
.iter()
.any(|(capability, version)| {
!compatibility.supports_capability(capability, *version)
})
{
return Err(incompatible(&instance.id));
}
let target_version = candidate.target_version.trim_start_matches('v');
semver::Version::parse(target_version).map_err(|_| invalid("目标网关版本不合法"))?;
// 下载阶段只校验发行身份;插件兼容性在用户点击重启时检查并确认。
validate_update_release(candidate.release_manifest.as_ref(), target_version)
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
Ok(self
.store
.load_instances()
.await
.map_err(map_store_error)?
.config_revision)
}
async fn validate_rollback(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
let plan = self.plan(Some(candidate)).await?;
if !plan.incompatible_plugins.is_empty() {
return Err(conflict("启用插件与回滚目标不兼容,请先停用对应插件"));
}
Ok(snapshot.config_revision)
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))
}
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> {
@@ -127,10 +110,6 @@ fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError
}
}
fn incompatible(instance_id: &str) -> SystemOperationError {
conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容"))
}
fn invalid(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Invalid, message)
}
@@ -142,3 +121,158 @@ fn conflict(message: impl Into<String>) -> SystemOperationError {
fn internal(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
}
impl PluginSystemUpdatePreflight {
pub(crate) async fn plan(
&self,
candidate: Option<SystemUpdateCandidate>,
) -> Result<SystemRestartPlan, SystemOperationError> {
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
let mut plan = SystemRestartPlan {
target_version: candidate.as_ref().map(|item| item.target_version.clone()),
release_manifest_sha256: candidate
.as_ref()
.map(|item| hex::encode(Sha256::digest(&item.release_manifest))),
config_revision: snapshot.config_revision.get(),
incompatible_plugins: Vec::new(),
};
let target = candidate
.as_ref()
.map(|item| semver::Version::parse(item.target_version.trim_start_matches('v')))
.transpose()
.map_err(|_| invalid("目标网关版本不合法"))?;
if let Some(candidate) = &candidate {
validate_update_release(
&candidate.release_manifest,
candidate.target_version.trim_start_matches('v'),
)
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
}
// 未知目标合同不能证明兼容,列入待确认停用;不伪造目标支持范围。
let host = candidate.as_ref().and_then(|item| {
update_compatibility(
&item.release_manifest,
item.target_version.trim_start_matches('v'),
)
.ok()
});
let mut inspected: BTreeMap<String, Option<String>> = BTreeMap::new();
for instance in snapshot.instances.iter().filter(|item| item.enabled) {
let reason = if let Some(reason) = inspected.get(&instance.artifact_sha256) {
reason.clone()
} else {
let artifact = self
.store
.load_artifact(&instance.artifact_sha256)
.await
.map_err(map_store_error)?;
let reason = if let Some(target) = &target {
match (
&host,
self.inspector
.compatibility(artifact.archive, instance.artifact_sha256.clone())
.await,
) {
(_, Err(error)) if error.kind() == AdminErrorKind::Invalid => {
Some("插件包或协议与目标版本不兼容".to_owned())
}
(_, Err(error)) => return Err(map_inspection_error(error)),
(None, _) => Some("无法确认插件与目标版本兼容".to_owned()),
(Some(host), Ok(requirements)) => {
if !semver::VersionReq::parse(&requirements.host_version)
.is_ok_and(|versions| versions.matches(target))
{
Some(format!("要求网关版本 {}", requirements.host_version))
} else if !host
.manifest_schema_versions
.contains(&requirements.manifest_schema_version)
|| !host
.protocol_versions
.contains(&requirements.protocol_version)
|| requirements
.capabilities
.iter()
.any(|(capability, version)| {
!host.supports_capability(capability, *version)
})
{
Some("插件协议或能力与目标版本不兼容".to_owned())
} else {
None
}
}
}
} else {
match self
.inspector
.inspect(artifact.archive, Some(instance.artifact_sha256.clone()))
.await
{
Ok(_) => None,
Err(error) if error.kind() == AdminErrorKind::Invalid => {
Some("插件与当前宿主不兼容".to_owned())
}
Err(error) => return Err(map_inspection_error(error)),
}
};
inspected.insert(instance.artifact_sha256.clone(), reason.clone());
reason
};
if let Some(reason) = reason {
plan.incompatible_plugins.push(SystemIncompatiblePlugin {
instance_id: instance.id.clone(),
name: instance.name.clone(),
reason,
});
}
}
plan.incompatible_plugins
.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
Ok(plan)
}
}
pub(crate) struct ConfirmedPluginRestart {
pub preflight: Arc<PluginSystemUpdatePreflight>,
pub confirmation: Option<SystemRestartPlan>,
pub context: MutationContext,
}
#[async_trait]
impl SystemRestartPreflight for ConfirmedPluginRestart {
async fn prepare(
&self,
candidate: Option<SystemUpdateCandidate>,
) -> Result<(), SystemOperationError> {
let plan = self.preflight.plan(candidate).await?;
if self
.confirmation
.as_ref()
.is_some_and(|confirmed| confirmed != &plan)
{
return Err(conflict("插件配置或目标版本已变化,请重新检查并确认重启"));
}
if !plan.incompatible_plugins.is_empty() && self.confirmation.is_none() {
return Err(conflict("请先确认停用不兼容插件,再重启服务"));
}
let mut revision =
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))?;
if !plan.incompatible_plugins.is_empty() {
let ids = plan
.incompatible_plugins
.iter()
.map(|item| item.instance_id.clone())
.collect::<Vec<_>>();
revision = self
.preflight
.store
.disable_instances(&ids, revision, &self.context)
.await
.map_err(map_store_error)?;
publish_committed(self.preflight.snapshots.as_ref(), revision)
.await
.map_err(|_| internal("插件已停用,但运行配置尚未发布,请重试重启"))?;
}
self.preflight.confirm_revision(revision).await
}
}
@@ -1,4 +1,7 @@
use std::{collections::BTreeSet, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use gateway_core::runtime::SnapshotControl;
use sha2::{Digest as _, Sha256};
@@ -49,6 +52,7 @@ pub struct PluginsService {
pub(super) preparation: Arc<dyn PluginPreparation>,
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
pub(super) state: PluginStateService,
pub(super) compatibility: tokio::sync::Mutex<BTreeMap<String, Option<String>>>,
}
impl PluginsService {
@@ -70,6 +74,7 @@ impl PluginsService {
preparation,
published,
state: PluginStateService::new(state),
compatibility: tokio::sync::Mutex::new(BTreeMap::new()),
}
}
@@ -6,7 +6,7 @@ use secrecy::ExposeSecret;
use super::PluginsService;
use crate::{
model::{
AdminError, MutationContext, Revision,
AdminError, AdminErrorKind, MutationContext, Revision,
plugins::instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
@@ -18,6 +18,36 @@ use crate::{
};
impl PluginsService {
/// 只校验固定包体,不启动插件;停用的旧版本仍需向管理端说明不能启用的原因。
async fn compatibility_warning(&self, digest: &str) -> Result<Option<String>, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包。
// 只缓存有限数量的确定结果,暂时性错误仍可重试。
let mut cache = self.compatibility.lock().await;
if let Some(warning) = cache.get(digest) {
return Ok(warning.clone());
}
let artifact = self
.store
.load_artifact(digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let warning = match self
.inspector
.inspect(artifact.archive, Some(digest.to_owned()))
.await
{
Ok(_) => None,
Err(error) if error.kind() == AdminErrorKind::Invalid => {
Some(error.message().to_owned())
}
Err(error) => return Err(error),
};
if cache.len() < 128 {
cache.insert(digest.to_owned(), warning.clone());
}
Ok(warning)
}
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
let snapshot = self
.store
@@ -52,10 +82,14 @@ impl PluginsService {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let configuration_required = !self
.preparation
.configuration_ready(instance.clone(), metadata)
let compatibility_warning = self
.compatibility_warning(&instance.artifact_sha256)
.await?;
let configuration_required = compatibility_warning.is_none()
&& !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
let running = instance.enabled
&& published_ready
@@ -76,6 +110,7 @@ impl PluginsService {
});
views.push(PluginInstanceView {
configuration_required,
compatibility_warning,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
@@ -162,6 +197,13 @@ impl PluginsService {
if artifact.accepted_at.is_none() {
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
}
if input.enabled
&& let Some(warning) = self.compatibility_warning(&input.artifact_sha256).await?
{
return Err(AdminError::invalid(format!(
"{warning},无法启动,请安装兼容版本"
)));
}
let same_plugin = |digest: &str| {
artifacts.iter().any(|item| {
item.metadata.sha256 == digest
@@ -34,7 +34,7 @@ struct SealedReleaseManifest {
sealed: bool,
gateway_version: String,
gateway_git_sha: String,
plugin_host: PluginHostCompatibility,
plugin_host: serde_json::Value,
plugins: Vec<OfficialPlugin>,
}
@@ -150,6 +150,7 @@ fn validate_manifest(
identity: &OfficialPluginReleaseIdentity,
) -> Result<ValidatedManifest, AdminError> {
let manifest = parse_manifest(bytes)?;
parse_host_compatibility(manifest.plugin_host)?;
if manifest.gateway_version != identity.gateway_version
|| manifest.gateway_git_sha != identity.gateway_git_sha
{
@@ -168,8 +169,39 @@ pub(crate) fn update_compatibility(
target_version: &str,
) -> Result<PluginHostCompatibility, AdminError> {
let manifest = parse_manifest(bytes)?;
(manifest.gateway_version == target_version)
.then_some(manifest.plugin_host)
if manifest.gateway_version != target_version {
return Err(invalid_manifest());
}
parse_host_compatibility(manifest.plugin_host)
}
pub(crate) fn validate_update_release(
bytes: &[u8],
target_version: &str,
) -> Result<(), AdminError> {
let manifest = parse_manifest(bytes)?;
if manifest.gateway_version != target_version {
return Err(invalid_manifest());
}
Ok(())
}
fn parse_host_compatibility(
mut value: serde_json::Value,
) -> Result<PluginHostCompatibility, AdminError> {
// 旧更新器要求 v1 与空权限字段,运行兼容性仍由清单、协议和能力版本表达。
if value.get("schema_version") == Some(&serde_json::json!(1))
&& value.get("permissions") == Some(&serde_json::json!([]))
{
let fields = value.as_object_mut().ok_or_else(invalid_manifest)?;
fields.remove("permissions");
fields.insert("schema_version".into(), serde_json::json!(2));
}
let compatibility: PluginHostCompatibility =
serde_json::from_value(value).map_err(|_| invalid_manifest())?;
compatibility
.is_valid()
.then_some(compatibility)
.ok_or_else(invalid_manifest)
}
@@ -183,7 +215,6 @@ fn parse_manifest(bytes: &[u8]) -> Result<SealedReleaseManifest, AdminError> {
|| !manifest.sealed
|| !valid_text(&manifest.gateway_version, MAXIMUM_VERSION_BYTES)
|| !valid_git_sha(&manifest.gateway_git_sha)
|| !manifest.plugin_host.is_valid()
|| manifest.plugins.len() > MAXIMUM_PLUGINS
{
return Err(invalid_manifest());
@@ -2,6 +2,8 @@
use std::sync::Arc;
use super::plugin_update::{ConfirmedPluginRestart, PluginSystemUpdatePreflight};
use crate::model::{MutationContext, system::SystemRestartPlan};
use async_trait::async_trait;
use crate::{
@@ -14,7 +16,6 @@ use crate::{
},
ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
SystemUpdatePreflight,
},
};
@@ -35,20 +36,25 @@ pub trait SystemService: Send + Sync {
) -> Result<SystemOperationAccepted, AdminError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError>;
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError>;
async fn restart(
&self,
confirmation: Option<SystemRestartPlan>,
context: &MutationContext,
) -> Result<SystemOperationAccepted, AdminError>;
}
/// 保持 Host 能力窄边界的默认系统用例。
pub(crate) struct DefaultSystemService {
operations: Arc<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
preflight: Arc<PluginSystemUpdatePreflight>,
}
impl DefaultSystemService {
#[must_use]
pub(crate) fn new(
operations: Arc<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
preflight: Arc<PluginSystemUpdatePreflight>,
) -> Self {
Self {
operations,
@@ -87,7 +93,7 @@ impl SystemService for DefaultSystemService {
.map(|version| version.trim().to_owned())
.filter(|version| !version.is_empty());
self.operations
.perform_update(target_version, channel, Arc::clone(&self.preflight))
.perform_update(target_version, channel, self.preflight.clone())
.await
.map_err(map_system_error)
}
@@ -101,13 +107,36 @@ impl SystemService for DefaultSystemService {
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
self.operations
.rollback(Arc::clone(&self.preflight))
.rollback(self.preflight.clone())
.await
.map_err(map_system_error)
}
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
self.operations.restart().await.map_err(map_system_error)
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError> {
let candidate = self
.operations
.restart_candidate()
.await
.map_err(map_system_error)?;
self.preflight
.plan(candidate)
.await
.map_err(map_system_error)
}
async fn restart(
&self,
confirmation: Option<SystemRestartPlan>,
context: &MutationContext,
) -> Result<SystemOperationAccepted, AdminError> {
self.operations
.restart(Arc::new(ConfirmedPluginRestart {
preflight: self.preflight.clone(),
confirmation,
context: context.clone(),
}))
.await
.map_err(map_system_error)
}
}
@@ -120,7 +149,7 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
};
let message = match kind {
AdminErrorKind::Invalid => "系统操作请求不合法",
AdminErrorKind::Conflict => "系统当前状态不允许执行该操作",
AdminErrorKind::Conflict => error.message(),
AdminErrorKind::BadGateway => "系统更新服务请求失败",
AdminErrorKind::Internal => "系统操作失败",
_ => "系统操作失败",
@@ -114,18 +114,14 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
}
#[test]
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
fn forecasts_use_actual_periods_and_extrapolate_total_capacity() {
let [week, month] = forecast(&quota(vec![window("week", 7)]));
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert!(!week.extrapolated);
assert!(month.extrapolated);
assert_eq!(month.estimated_tokens, Some(21_429));
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
@@ -203,8 +199,8 @@ fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
source.windows[0].used_percent = Some(100.0);
let [week, _] = forecast(&source);
assert!(!week.low_sample);
assert_eq!(week.remaining_tokens, Some(0));
assert_eq!(week.remaining_usd, Some(0.0));
assert_eq!(week.estimated_tokens, Some(1_000));
assert_eq!(week.estimated_usd, Some(2.0));
}
#[test]
@@ -257,8 +253,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
assert_eq!(week.estimated_tokens, Some(9_000));
assert_eq!(week.remaining_tokens, Some(8_000));
assert_eq!(month.remaining_tokens, Some(8_000));
assert_eq!(month.estimated_tokens, Some(38_571));
assert!(week.unavailable_reason.is_none());
assert!(!week.low_sample);
}
@@ -273,7 +268,6 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
assert!(week.incomplete_tokens);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.source.unwrap().tokens, Some(1_000));
}
@@ -308,13 +302,12 @@ fn partial_costs_keep_estimates_from_known_amounts() {
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(week.estimated_tokens, Some(5_000));
}
}
#[test]
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
fn missing_tokens_and_costs_do_not_block_recorded_capacity_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
@@ -331,11 +324,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(month.estimated_tokens, Some(21_429));
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
@@ -352,7 +341,6 @@ fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
@@ -370,8 +358,6 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_tokens, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
@@ -420,11 +406,8 @@ fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast()
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
assert!(
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
);
@@ -445,6 +428,4 @@ fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.estimated_tokens, Some(1_000));
assert_eq!(cycle.estimated_usd, Some(2.0));
assert_eq!(cycle.remaining_tokens, Some(0));
assert_eq!(cycle.remaining_usd, Some(0.0));
}
@@ -2430,7 +2430,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
.unwrap();
assert!(result.forecasts[0].unavailable_reason.is_none());
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
assert_eq!(store.quota_window_queries()[0].range.start, added);
store
.quota_forecast_history
@@ -2471,7 +2470,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
let cycle = &result.forecasts[0];
assert!(cycle.unavailable_reason.is_none());
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
assert_eq!(cycle.remaining_tokens, Some(429));
assert_eq!(cycle.estimated_tokens, Some(679));
}
@@ -44,7 +44,7 @@ use gateway_admin::{
observability::{
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
@@ -770,7 +770,7 @@ impl ObservabilityStore for UnavailableStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Err(unavailable("usage diagnostics"))
}
@@ -1086,7 +1086,10 @@ impl SystemOperations for UnavailableSystem {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn restart(
&self,
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
@@ -16,11 +16,11 @@ use gateway_admin::{
MutationContext, PageSize, Revision,
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
UsagePage, UsageQuery, china_day_start,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -421,7 +421,10 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.expect("calculated total"),
},
}]);
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 4,
items: vec![diagnostic("openai", 3), diagnostic("xai", 1)],
});
let services = observability_services_with_calculated_billing(store).await;
let insights = services
@@ -513,7 +516,10 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
let mut failure = diagnostic("single_failure", 1);
failure.success_count = 0;
failure.failure_count = 1;
store.replace_diagnostics(vec![success, failure]);
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 101,
items: vec![success, failure],
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
@@ -529,6 +535,68 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
assert_eq!(result.items[1].error_rate, 1.0);
}
#[tokio::test]
async fn diagnostics_should_use_the_total_before_truncating_groups() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 200,
items: (0..100)
.map(|index| diagnostic(&format!("model-{index}"), 1))
.collect(),
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("limited diagnostics");
assert!(result.items.iter().all(|item| item.request_share == 0.005));
}
#[tokio::test]
async fn diagnostics_should_count_each_retried_request_once() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let mut item = diagnostic("model", 8);
item.attempt_count = 14;
item.retry_count = 6;
item.retried_request_count = 2;
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 8,
items: vec![item],
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("retry diagnostics");
assert_eq!(
(result.items[0].retry_count, result.items[0].retry_rate),
(6, 0.25)
);
}
#[tokio::test]
async fn diagnostics_should_preserve_empty_results() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Account)
.await
.expect("empty diagnostics");
assert!(result.items.is_empty());
}
#[tokio::test]
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
let now = Utc::now();
@@ -632,7 +700,7 @@ struct FixtureObservabilityStore {
overview: Mutex<UsageOverview>,
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
billing_stream_fails: AtomicBool,
diagnostics: Mutex<Vec<DiagnosticObservation>>,
diagnostics: Mutex<DiagnosticsObservation>,
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
@@ -653,7 +721,7 @@ impl FixtureObservabilityStore {
}),
calculated_billing_facts: Mutex::new(Vec::new()),
billing_stream_fails: AtomicBool::new(false),
diagnostics: Mutex::new(Vec::new()),
diagnostics: Mutex::new(DiagnosticsObservation::default()),
runtime_slots: Mutex::new(None),
summary_observed_at: Mutex::new(None),
slots_observed_at: Mutex::new(None),
@@ -686,7 +754,7 @@ impl FixtureObservabilityStore {
.expect("calculated billing facts") = facts;
}
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
fn replace_diagnostics(&self, diagnostics: DiagnosticsObservation) {
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
}
@@ -798,7 +866,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -948,6 +1016,8 @@ fn total_record(
provider_account_name: None,
provider_account_email: None,
provider_account_notes: None,
provider_account_plan_type: None,
provider_account_plan_type_display: None,
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
@@ -1010,6 +1080,9 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
first_token_p95_ms: None,
non_completion_count: 0,
retry_count: 0,
retried_request_count: 0,
account_provider_kind: None,
account_plan_type: None,
key: name.to_owned(),
name: name.to_owned(),
request_count,
@@ -28,6 +28,8 @@ use gateway_admin::{
struct Fixture {
revisions: std::sync::Mutex<Vec<Revision>>,
requirements: PluginCompatibilityRequirements,
instances: Vec<PluginInstance>,
disabled: std::sync::Mutex<Vec<String>>,
}
impl Fixture {
@@ -40,6 +42,8 @@ impl Fixture {
.collect(),
),
requirements,
instances: vec![instance(true)],
disabled: std::sync::Mutex::new(Vec::new()),
})
}
@@ -95,18 +99,17 @@ impl PluginStore for Fixture {
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
Ok(PluginInstanceSnapshot {
config_revision: self.next_revision(),
instances: vec![PluginInstance {
id: "enabled-fixture".into(),
name: "Enabled fixture".into(),
artifact_sha256: "a".repeat(64),
enabled: true,
trusted_process: true,
configuration: serde_json::json!({}),
secrets: BTreeMap::new(),
bindings: Vec::new(),
revision: Revision::new(1).expect("revision"),
}],
instances: self
.instances
.iter()
.cloned()
.map(|mut instance| {
if self.disabled.lock().unwrap().contains(&instance.id) {
instance.enabled = false;
}
instance
})
.collect(),
})
}
@@ -135,6 +138,24 @@ impl PluginStore for Fixture {
})
}
async fn disable_instances(
&self,
ids: &[String],
expected: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
let mut revisions = self.revisions.lock().unwrap();
if revisions[0] != expected {
return Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::StaleRevision,
"plugin",
"stale",
));
}
revisions[0] = Revision::new(expected.get() + 1).unwrap();
self.disabled.lock().unwrap().extend_from_slice(ids);
Ok(revisions[0])
}
async fn save_instance(
&self,
_: PluginInstance,
@@ -273,13 +294,31 @@ impl SystemOperations for PreflightingSystem {
async fn rollback(
&self,
_: Arc<dyn SystemUpdatePreflight>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!()
let revision = preflight.validate_rollback(self.candidate.clone()).await?;
preflight.confirm_revision(revision).await?;
Ok(SystemOperationAccepted::Rollback {
operation_id: "fixture".into(),
message: "accepted".into(),
need_restart: true,
})
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!()
async fn restart_candidate(
&self,
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
Ok(Some(self.candidate.clone()))
}
async fn restart(
&self,
preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
preflight.prepare(Some(self.candidate.clone())).await?;
Ok(SystemOperationAccepted::Restart {
operation_id: "fixture".into(),
message: "accepted".into(),
})
}
}
@@ -302,7 +341,7 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision()
}
#[tokio::test]
async fn system_update_rejects_missing_target_capability_and_revision_changes() {
async fn rollback_rejects_missing_target_capability() {
let incompatible = Fixture::new(requirements("^1.0", "executor"), &[7]);
let services = super::AdminHarness::new()
.plugins(incompatible.clone(), incompatible)
@@ -311,14 +350,11 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
}))
.build()
.await;
assert!(
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
assert!(services.system().rollback().await.is_err());
}
#[tokio::test]
async fn system_update_rejects_revision_changes() {
let stale = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
let services = super::AdminHarness::new()
.plugins(stale.clone(), stale)
@@ -336,6 +372,97 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
);
}
#[tokio::test]
async fn system_update_accepts_new_plugin_contracts_for_empty_disabled_and_enabled_plugins() {
for instances in [vec![], vec![instance(false)], vec![instance(true)]] {
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
Arc::get_mut(&mut fixture)
.expect("exclusive fixture")
.instances = instances;
let mut candidate = candidate("models");
let mut manifest: serde_json::Value =
serde_json::from_slice(&candidate.release_manifest).unwrap();
manifest["plugin_host"] =
serde_json::json!({"schema_version": 99, "future_contract": true});
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture)
.system(Arc::new(PreflightingSystem { candidate }))
.build()
.await;
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.expect("plugin confirmation is deferred until restart");
}
}
#[tokio::test]
async fn system_update_keeps_release_identity_validation_without_plugins() {
for (field, value) in [
("gateway_version", serde_json::json!("1.3.0")),
("gateway_git_sha", serde_json::json!("invalid")),
("sealed", serde_json::json!(false)),
("schema_version", serde_json::json!(99)),
] {
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
Arc::get_mut(&mut fixture).unwrap().instances.clear();
let mut candidate = candidate("executor");
let mut manifest: serde_json::Value =
serde_json::from_slice(&candidate.release_manifest).unwrap();
manifest[field] = value;
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture)
.system(Arc::new(PreflightingSystem { candidate }))
.build()
.await;
assert!(
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.is_err(),
"{field}"
);
}
}
#[tokio::test]
async fn system_update_still_rechecks_revision_without_plugins() {
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
Arc::get_mut(&mut fixture).unwrap().instances.clear();
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture)
.system(Arc::new(PreflightingSystem {
candidate: candidate("executor"),
}))
.build()
.await;
assert!(
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
}
fn instance(enabled: bool) -> PluginInstance {
PluginInstance {
id: "enabled-fixture".into(),
name: "Enabled fixture".into(),
artifact_sha256: "a".repeat(64),
enabled,
trusted_process: true,
configuration: serde_json::json!({}),
secrets: BTreeMap::new(),
bindings: Vec::new(),
revision: Revision::new(1).expect("revision"),
}
}
fn requirements(host_version: &str, capability: &str) -> PluginCompatibilityRequirements {
PluginCompatibilityRequirements {
host_version: host_version.into(),
@@ -365,3 +492,106 @@ fn candidate(capability: &str) -> SystemUpdateCandidate {
release_manifest: manifest.into(),
}
}
fn context() -> MutationContext {
MutationContext {
actor: gateway_admin::model::MutationActor::AdminApiKey,
request_id: "restart-test".into(),
}
}
#[tokio::test]
async fn incompatible_restart_requires_exact_confirmation_before_disabling() {
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture.clone())
.system(Arc::new(PreflightingSystem {
candidate: candidate("models"),
}))
.build()
.await;
let plan = services.system().restart_plan().await.unwrap();
assert_eq!(plan.incompatible_plugins.len(), 1);
assert_eq!(plan.incompatible_plugins[0].name, "Enabled fixture");
assert!(
fixture.disabled.lock().unwrap().is_empty(),
"检查及取消不能停用插件"
);
assert!(services.system().restart(None, &context()).await.is_err());
for altered in 0..3 {
let mut changed = plan.clone();
match altered {
0 => changed.config_revision += 1,
1 => changed.release_manifest_sha256 = Some("b".repeat(64)),
_ => changed.incompatible_plugins.clear(),
}
assert!(
services
.system()
.restart(Some(changed), &context())
.await
.is_err()
);
assert!(fixture.disabled.lock().unwrap().is_empty());
}
services
.system()
.restart(Some(plan), &context())
.await
.unwrap();
assert_eq!(*fixture.disabled.lock().unwrap(), ["enabled-fixture"]);
assert!(
services
.system()
.restart_plan()
.await
.unwrap()
.incompatible_plugins
.is_empty()
);
}
#[tokio::test]
async fn compatible_restart_does_not_change_plugin_configuration() {
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture.clone())
.system(Arc::new(PreflightingSystem {
candidate: candidate("executor"),
}))
.build()
.await;
assert!(
services
.system()
.restart_plan()
.await
.unwrap()
.incompatible_plugins
.is_empty()
);
services.system().restart(None, &context()).await.unwrap();
assert!(fixture.disabled.lock().unwrap().is_empty());
assert_eq!(fixture.next_revision().get(), 7);
}
#[tokio::test]
async fn changed_configuration_after_restart_check_requires_a_new_confirmation() {
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture.clone())
.system(Arc::new(PreflightingSystem {
candidate: candidate("models"),
}))
.build()
.await;
let plan = services.system().restart_plan().await.unwrap();
assert!(
services
.system()
.restart(Some(plan), &context())
.await
.is_err()
);
assert!(fixture.disabled.lock().unwrap().is_empty());
}
@@ -70,7 +70,7 @@ impl Fixture {
let unused = Arc::new(TestPluginPorts);
PluginsService::new(
self.clone(),
unused.clone(),
self.clone(),
PluginDistributionPorts::new(unused.clone(), unused.clone()),
self.clone(),
self.clone(),
@@ -87,6 +87,20 @@ impl SnapshotControl for Fixture {
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture {
async fn inspect(
&self,
archive: Arc<[u8]>,
_: Option<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
Ok(InspectedPluginArtifact {
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
archive,
})
}
}
struct Lease;
impl ExtensionSetLease for Lease {
@@ -260,7 +274,10 @@ impl PluginStore for Fixture {
}
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
Err(store_error(AdminStoreErrorKind::Unavailable))
Ok(InspectedPluginArtifact {
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
archive: Arc::from([1_u8]),
})
}
async fn install_artifact(
@@ -59,6 +59,28 @@ enum MigrationBehavior {
ConcurrentChangeThenFail,
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture {
async fn inspect(
&self,
archive: Arc<[u8]>,
digest: Option<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
let data = self.data.lock().unwrap();
if let Some(kind) = data.inspection_error {
return Err(AdminError::new(kind, "插件与当前宿主不兼容"));
}
let metadata = data
.artifacts
.iter()
.find(|item| Some(&item.metadata.sha256) == digest.as_ref())
.unwrap()
.metadata
.clone();
Ok(InspectedPluginArtifact { metadata, archive })
}
}
struct SavedInstance {
artifact_sha256: String,
enabled: bool,
@@ -69,6 +91,8 @@ struct FixtureData {
snapshot: PluginInstanceSnapshot,
artifacts: Vec<InstalledPluginArtifact>,
artifact_reads: usize,
archive_reads: usize,
inspection_error: Option<AdminErrorKind>,
change_during_artifact_read: bool,
fail_preparation: bool,
configuration_ready: bool,
@@ -112,6 +136,8 @@ impl LifecycleFixture {
artifact(NEW_ARTIFACT, "test.lifecycle", "1.0.0"),
],
artifact_reads: 0,
archive_reads: 0,
inspection_error: None,
change_during_artifact_read: false,
fail_preparation: false,
configuration_ready: true,
@@ -512,8 +538,20 @@ impl PluginStore for LifecycleFixture {
Ok(data.artifacts.clone())
}
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
Err(admin_error(AdminStoreErrorKind::Unavailable))
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact> {
let mut data = self.data.lock().unwrap();
data.archive_reads += 1;
let metadata = data
.artifacts
.iter()
.find(|item| item.metadata.sha256 == digest)
.unwrap()
.metadata
.clone();
Ok(InspectedPluginArtifact {
metadata,
archive: Arc::from([1_u8]),
})
}
async fn install_artifact(
@@ -553,7 +591,7 @@ impl SnapshotControl for Published {
fn service(fixture: Arc<LifecycleFixture>, published: Arc<Published>) -> PluginsService {
PluginsService::new(
fixture.clone(),
Arc::new(TestPluginPorts),
fixture.clone(),
PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)),
published,
fixture.clone(),
@@ -637,7 +675,7 @@ fn input() -> ConfigurePluginInstance {
}
#[tokio::test]
async fn instance_list_loads_metadata_once_for_shared_artifacts_without_loading_archives() {
async fn instance_list_checks_shared_artifact_compatibility_once() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let mut second = fixture.snapshot().instances[0].clone();
second.id = "00000000-0000-7000-8000-000000000002".into();
@@ -1452,3 +1490,48 @@ async fn version_plan_preserves_observer_event_scopes_and_disabled_subscriptions
assert!(fixture.data.lock().unwrap().saves.is_empty());
}
}
#[tokio::test]
async fn incompatible_disabled_plugin_has_warning_and_cannot_be_enabled() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
{
let mut data = fixture.data.lock().unwrap();
data.snapshot.instances[0].enabled = false;
data.inspection_error = Some(AdminErrorKind::Invalid);
}
let service = service(fixture.clone(), Arc::new(Published::default()));
let view = service.instances().await.unwrap().remove(0);
assert!(view.compatibility_warning.is_some());
assert!(!view.instance.enabled);
let mut request = input();
request.artifact_sha256 = OLD_ARTIFACT.into();
let error = service
.configure_instance(Some(&view.instance.id), request, &context())
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Invalid);
assert!(fixture.data.lock().unwrap().saves.is_empty());
assert_eq!(
fixture.data.lock().unwrap().archive_reads,
1,
"固定摘要的静态检查结论可复用"
);
service.instances().await.unwrap();
assert_eq!(fixture.data.lock().unwrap().archive_reads, 1);
}
#[tokio::test]
async fn transient_compatibility_check_failure_can_be_retried() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().inspection_error = Some(AdminErrorKind::Unavailable);
let service = service(fixture.clone(), Arc::new(Published::default()));
assert!(service.instances().await.is_err());
fixture.data.lock().unwrap().inspection_error = None;
assert!(
service.instances().await.unwrap()[0]
.compatibility_warning
.is_none()
);
assert_eq!(fixture.data.lock().unwrap().archive_reads, 2);
}
@@ -288,6 +288,40 @@ async fn official_release_validates_all_packages_then_uses_builtin_install_path(
assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]);
}
#[tokio::test]
async fn official_release_accepts_legacy_update_envelope_with_current_plugin_contracts() {
let mut fixture = Fixture::new(&[]);
let mut manifest: serde_json::Value =
serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap();
manifest["plugin_host"]["schema_version"] = serde_json::json!(1);
manifest["plugin_host"]["permissions"] = serde_json::json!([]);
Arc::get_mut(&mut fixture).unwrap().manifest =
Some(serde_json::to_vec(&manifest).unwrap().into());
let imported = fixture
.service()
.import_official_release(fixture.as_ref(), &identity(), &system_context())
.await
.unwrap();
assert_eq!(imported.artifacts, 0);
}
#[tokio::test]
async fn official_release_still_rejects_an_unknown_host_contract_at_startup() {
let mut fixture = Fixture::new(&[]);
let mut manifest: serde_json::Value =
serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap();
manifest["plugin_host"]["schema_version"] = serde_json::json!(99);
Arc::get_mut(&mut fixture).unwrap().manifest =
Some(serde_json::to_vec(&manifest).unwrap().into());
assert!(
fixture
.service()
.import_official_release(fixture.as_ref(), &identity(), &system_context())
.await
.is_err()
);
}
#[tokio::test]
async fn official_release_never_persists_a_valid_prefix_when_later_validation_fails() {
let mut fixture = Fixture::new(&[("test.official-1", 1), ("test.official-2", 2)]);
@@ -106,7 +106,10 @@ impl SystemOperations for RecordingSystemOperations {
})
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn restart(
&self,
_preflight: std::sync::Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Ok(SystemOperationAccepted::Restart {
operation_id: "operation-restart".to_owned(),
message: "accepted".to_owned(),
@@ -180,10 +180,6 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
estimated_usd: forecast.estimated_usd,
estimated_usd_display: forecast_usd_display(forecast.estimated_usd),
remaining_tokens: forecast.remaining_tokens,
remaining_tokens_display: display_optional_tokens(forecast.remaining_tokens),
remaining_usd: forecast.remaining_usd,
remaining_usd_display: forecast_usd_display(forecast.remaining_usd),
}
}
@@ -362,10 +362,6 @@ pub struct AccountQuotaForecastView {
pub estimated_tokens_display: String,
pub estimated_usd: Option<f64>,
pub estimated_usd_display: String,
pub remaining_tokens: Option<u64>,
pub remaining_tokens_display: String,
pub remaining_usd: Option<f64>,
pub remaining_usd_display: String,
}
#[derive(Debug, Clone, Serialize)]
@@ -287,6 +287,8 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
account_email: record.provider_account_email,
account_name: record.provider_account_name,
account_notes: record.provider_account_notes,
account_plan_type: record.provider_account_plan_type,
account_plan_type_display: record.provider_account_plan_type_display,
route: record.endpoint,
model,
requested_model: record.requested_model_id,
@@ -581,6 +583,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
account_id: error.provider_account_ref,
account_name: error.provider_account_name,
account_email: error.provider_account_email,
account_plan_type: error.provider_account_plan_type,
account_plan_type_display: error.provider_account_plan_type_display,
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
model,
requested_model: error.requested_model_id,
@@ -1283,6 +1287,8 @@ pub(crate) fn diagnostics_view(
item.name
},
request_count: item.request_count,
account_plan_type: item.account_plan_type,
account_plan_type_display: item.account_plan_type_display,
success_count: item.success_count,
error_count: item.error_count,
error_rate: item.error_rate,
@@ -94,6 +94,8 @@ pub struct UsageListRecordView {
pub account_email: Option<String>,
pub account_name: Option<String>,
pub account_notes: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -746,6 +748,8 @@ pub struct UsageInsightsOverviewView {
pub struct DiagnosticItemView {
pub key: String,
pub name: String,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -788,6 +792,8 @@ pub struct OpsErrorView {
pub account_id: Option<String>,
pub account_name: Option<String>,
pub account_email: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -52,6 +52,7 @@ struct InstanceView {
artifact_sha256: String,
enabled: bool,
configuration_required: bool,
compatibility_warning: Option<String>,
configuration: serde_json::Value,
secret_fields: Vec<String>,
bindings: Vec<PluginCapabilityBinding>,
@@ -146,6 +147,7 @@ impl From<PluginInstanceView> for InstanceView {
artifact_sha256,
enabled,
configuration_required: value.configuration_required,
compatibility_warning: value.compatibility_warning,
configuration,
secret_fields: secrets.into_keys().collect(),
bindings,
+33 -4
View File
@@ -20,8 +20,8 @@ use axum::{
use futures::{Stream, StreamExt};
use gateway_admin::model::system::{
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
};
use serde::{Deserialize, Serialize};
@@ -114,6 +114,7 @@ struct SystemUpdateDetailView {
notes: Option<String>,
cached: bool,
update_supported: bool,
restart_confirmation_supported: bool,
unsupported_reason: Option<String>,
warning: Option<String>,
}
@@ -133,6 +134,7 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
notes: detail.notes,
cached: detail.cached,
update_supported: detail.update_supported,
restart_confirmation_supported: true,
unsupported_reason: detail.unsupported_reason,
warning: detail.warning,
}
@@ -239,6 +241,7 @@ where
.route("/api/admin/system/update", post(perform_update::<S>))
.route("/api/admin/system/update/status", get(update_status::<S>))
.route("/api/admin/system/rollback", post(rollback::<S>))
.route("/api/admin/system/restart/check", get(restart_plan::<S>))
.route("/api/admin/system/restart", post(restart::<S>))
}
@@ -387,17 +390,43 @@ where
))
}
async fn restart<S>(
async fn restart_plan<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let plan = state
.admin_services()
.system()
.restart_plan()
.await
.map_err(map_system_error)?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(plan)))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RestartRequest {
confirmation: Option<SystemRestartPlan>,
}
async fn restart<S>(
auth: AdminAuth,
State(state): State<S>,
payload: Option<AdminJson<RestartRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.system()
.restart()
.restart(
payload.and_then(|AdminJson(request)| request.confirmation),
&auth.context().mutation_context(),
)
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Restart {
@@ -29,13 +29,13 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
incomplete_tokens: false,
estimated_tokens: Some(1_000_000),
estimated_usd: None,
remaining_tokens: Some(0),
remaining_usd: None,
};
let mut monthly = forecast.clone();
monthly.period = AccountUsagePeriod::Monthly;
monthly.extrapolated = true;
monthly.target_seconds = 30 * 86_400;
monthly.estimated_tokens = Some(0);
monthly.estimated_usd = Some(0.0);
let view = AccountQuotaForecastData::from(AccountQuotaForecastReport {
account_id: "acct_forecast".to_owned(),
generated_at: now,
@@ -49,7 +49,16 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
assert_eq!(week["estimatedTokensDisplay"], "1M");
assert!(week["estimatedUsd"].is_null());
assert_eq!(week["estimatedUsdDisplay"], "—");
assert_eq!(week["remainingTokensDisplay"], "0");
for forecast in value["forecasts"].as_array().unwrap() {
for field in [
"remainingTokens",
"remainingTokensDisplay",
"remainingUsd",
"remainingUsdDisplay",
] {
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
}
}
assert_eq!(
week["source"],
serde_json::json!({
@@ -66,6 +75,10 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
assert!(week.get("method").is_none());
assert!(week.get("methodDisplay").is_none());
assert!(value.get("generatedAtDisplay").is_none());
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
assert_eq!(value["forecasts"][1]["estimatedUsdDisplay"], "$0.00");
assert_eq!(value["forecasts"][1]["period"], "monthly");
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
assert_eq!(value["forecasts"][1]["extrapolated"], true);
@@ -35,7 +35,7 @@ use gateway_admin::{
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
observability::{
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticObservation,
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticsObservation,
OpsError, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail,
UsageFilter, UsageListRecord, UsageOverview, UsagePage, UsageQuery,
},
@@ -100,7 +100,7 @@ pub(super) struct AdminTestFixture {
pub settings: Arc<MemorySettingsStore>,
pub usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
pub usage_detail: Arc<Mutex<Option<UsageDetail>>>,
pub diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
pub diagnostics: Arc<Mutex<DiagnosticsObservation>>,
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
@@ -137,7 +137,7 @@ impl AdminTestFixture {
let account_groups = Arc::new(MemoryAccountGroupStore::new());
let usage_records = Arc::new(Mutex::new(Vec::new()));
let usage_detail = Arc::new(Mutex::new(None));
let diagnostics = Arc::new(Mutex::new(Vec::new()));
let diagnostics = Arc::new(Mutex::new(DiagnosticsObservation::default()));
let ops_errors = Arc::new(Mutex::new(Vec::new()));
let dashboard_observation = Arc::new(Mutex::new(None));
let dashboard_summary_range = Arc::new(Mutex::new(None));
@@ -1000,7 +1000,7 @@ struct UnusedStore {
observations: Arc<Mutex<MemoryObservations>>,
usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
diagnostics: Arc<Mutex<DiagnosticsObservation>>,
ops_errors: Arc<Mutex<Vec<OpsError>>>,
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
@@ -1312,7 +1312,7 @@ impl ObservabilityStore for UnusedStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -1520,7 +1520,10 @@ impl SystemOperations for UnusedSystem {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn restart(
&self,
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
@@ -447,6 +447,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
provider_account_ref: Some("acct_err".to_owned()),
provider_account_name: None,
provider_account_email: Some("err@example.invalid".to_owned()),
provider_account_plan_type: Some("pro".to_owned()),
provider_account_plan_type_display: Some("Pro".to_owned()),
provider_account_authentication_kind: Some("api_key".to_owned()),
upstream_model_id: Some("upstream-err".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
@@ -509,6 +511,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"kind": value["data"]["items"][0]["kind"],
"accountId": value["data"]["items"][0]["accountId"],
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
"accountPlanType": value["data"]["items"][0]["accountPlanType"],
"accountPlanTypeDisplay": value["data"]["items"][0]["accountPlanTypeDisplay"],
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
"route": value["data"]["items"][0]["route"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
@@ -528,6 +532,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"kind": "model_request",
"accountId": "acct_err",
"accountLabel": "err@example.invalid",
"accountPlanType": "pro",
"accountPlanTypeDisplay": "Pro",
"clientStatusCode": 502,
"route": "/v1/responses",
"requestedModel": "gpt-5.4",
@@ -550,7 +556,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use gateway_admin::model::observability::{CostCoverage, DiagnosticObservation};
use gateway_admin::model::observability::{
CostCoverage, DiagnosticObservation, DiagnosticsObservation,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
@@ -558,26 +566,28 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
fixture
.diagnostics
.lock()
.expect("diagnostics")
.push(DiagnosticObservation {
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
total_request_count: 4,
items: vec![DiagnosticObservation {
key: "acct_diag".to_owned(),
account_provider_kind: Some("openai".to_owned()),
account_plan_type: Some("pro".to_owned()),
name: "diag@example.invalid".to_owned(),
request_count: 2,
success_count: 2,
failure_count: 0,
attempt_count: 2,
attempt_count: 5,
total_tokens: 200,
average_latency_ms: Some(100),
latency_p95_ms: Some(3800),
first_token_p95_ms: Some(1200),
non_completion_count: 0,
retry_count: 0,
retry_count: 3,
retried_request_count: 1,
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
});
}],
};
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
@@ -596,6 +606,11 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert!(value["data"]["items"][0].get("impactScore").is_none());
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro");
assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro");
assert_eq!(
(
&value["data"]["items"][0]["key"],
@@ -766,6 +781,8 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_name: Some("Snapshot Alpha".to_owned()),
provider_account_email: Some("alpha@example.invalid".to_owned()),
provider_account_notes: Some("Team workspace".to_owned()),
provider_account_plan_type: Some("supergrok_plus".to_owned()),
provider_account_plan_type_display: None,
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
@@ -858,6 +875,14 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
assert_eq!(
value["data"]["items"][0]["accountPlanType"],
"supergrok_plus"
);
assert_eq!(
value["data"]["items"][0]["accountPlanTypeDisplay"],
"SupergrokPlus"
);
assert_eq!(
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
true
@@ -891,6 +891,12 @@ impl PluginStore for TestPluginPorts {
Ok(vec![accepted_artifact()])
}
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact> {
if digest == "a".repeat(64) {
return Ok(InspectedPluginArtifact {
metadata: accepted_artifact().metadata,
archive: Arc::from([1_u8]),
});
}
if matches!(digest.as_bytes().first(), Some(b'e' | b'f')) && digest.len() == 64 {
return Ok(icon_artifact(digest));
}
@@ -929,7 +935,10 @@ impl PluginPackageInspector for TestPluginPorts {
_: Arc<[u8]>,
_: Option<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
Ok(InspectedPluginArtifact {
metadata: accepted_artifact().metadata,
archive: Arc::from([1_u8]),
})
}
async fn icon(
@@ -204,7 +204,10 @@ impl SystemOperations for ProgressSystem {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn restart(
&self,
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
@@ -100,6 +100,8 @@ fn usage_record() -> UsageListRecord {
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_notes: Some("private-sentinel".to_owned()),
provider_account_plan_type: Some("private-sentinel".to_owned()),
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
@@ -166,6 +168,8 @@ fn error_record() -> OpsError {
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
provider_account_plan_type: Some("private-sentinel".to_owned()),
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
failure_kind: "private-sentinel".to_owned(),
+4 -1
View File
@@ -133,7 +133,10 @@ impl SystemOperations for VersionSystem {
unreachable!("client route must not roll back")
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn restart(
&self,
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!("client route must not restart")
}
}
+1
View File
@@ -47,6 +47,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
[dev-dependencies]
filetime = "0.2.27"
hyper = { version = "=1.11.1", default-features = false, features = ["server"] }
tempfile = "3.27.0"
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
wiremock = "0.6.5"
@@ -263,18 +263,18 @@ impl HttpClient {
.as_str()
.parse()
.map_err(|_| HttpError::invalid("URL"))?;
let authority = uri
.authority()
.ok_or_else(|| HttpError::invalid("host"))?
.as_str();
headers.insert(
http::header::HOST,
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
);
if url.scheme() == "http"
&& let Some(proxy) = proxy.filter(|proxy| proxy.expose_url().starts_with("http"))
{
// 正向代理接收数字地址的绝对 URI;Host 保留原始域名,禁止代理再次解析目标。
let authority = uri
.authority()
.ok_or_else(|| HttpError::invalid("host"))?
.as_str();
headers.insert(
http::header::HOST,
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
);
let address = addresses
.first()
.ok_or_else(|| HttpError::invalid("address"))?;
@@ -129,6 +129,7 @@ impl Downloads {
continue;
}
if !(200..300).contains(&response.status) {
tracing::warn!(status = response.status, "插件来源返回非成功状态");
return Err(source_error());
}
if header(&response.headers, "content-length")
@@ -23,8 +23,8 @@ use gateway_admin::model::system::{
SystemUpdateStatus, SystemVersion,
};
use gateway_admin::ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateCandidate,
SystemUpdateEventStream, SystemUpdatePreflight,
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemRestartPreflight,
SystemUpdateCandidate, SystemUpdateEventStream, SystemUpdatePreflight,
};
use gateway_core::lifecycle::CancellationToken;
use serde::Deserialize;
@@ -452,14 +452,14 @@ impl ProcessSystemOperations {
self.events.info(
Some(operation_id),
Some("preflight"),
"正在检查启用插件与目标版本的兼容性",
"正在校验目标发行信息",
);
let plugin_revision = preflight.validate(candidate).await?;
preflight.confirm_revision(plugin_revision).await?;
self.events.success(
Some(operation_id),
Some("preflight"),
"启用插件兼容性检查通过",
"目标发行信息校验通过,插件兼容性将在重启前检查",
);
self.events
.info(Some(operation_id), Some("replace"), "正在替换应用文件");
@@ -499,6 +499,23 @@ impl ProcessSystemOperations {
Ok(channel)
}
fn installed_restart_candidate(&self) -> Result<Option<SystemUpdateCandidate>, OperationError> {
if self.config.build_type == "source" {
return Ok(None);
}
let release_manifest = read_release_manifest(&self.config.official_plugins_dir()?)?;
#[derive(Deserialize)]
struct ReleaseIdentity {
gateway_version: String,
}
let identity: ReleaseIdentity = serde_json::from_slice(&release_manifest)
.map_err(|_| invalid("已安装发行清单缺少目标版本"))?;
Ok(Some(SystemUpdateCandidate {
target_version: identity.gateway_version,
release_manifest,
}))
}
fn reconcile_installation(&self) -> Result<SystemUpdateStatus, OperationError> {
let running_files = self.running_files.as_ref().as_ref().map_err(Clone::clone)?;
reconcile_installation(&self.config, running_files)
@@ -610,7 +627,7 @@ impl SystemOperations for ProcessSystemOperations {
let release_manifest =
read_release_manifest(&rollback_official_plugins_dir(&self.config)?)?;
let plugin_revision = preflight
.validate(SystemUpdateCandidate {
.validate_rollback(SystemUpdateCandidate {
target_version,
release_manifest: Arc::clone(&release_manifest),
})
@@ -671,7 +688,18 @@ impl SystemOperations for ProcessSystemOperations {
})
}
async fn restart(&self) -> Result<SystemOperationAccepted, OperationError> {
async fn restart_candidate(&self) -> Result<Option<SystemUpdateCandidate>, OperationError> {
let _operation = self
.operation_lock
.try_lock()
.map_err(|_| conflict("system operation is already running"))?;
self.installed_restart_candidate()
}
async fn restart(
&self,
preflight: Arc<dyn SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, OperationError> {
// 与 update/rollback 互斥:更新替换文件期间触发自重启会让新进程
// 载入半成品产物。
let _operation = self
@@ -681,6 +709,18 @@ impl SystemOperations for ProcessSystemOperations {
if !self.config.self_restart_enabled {
return Err(conflict("self restart is disabled"));
}
let _file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let candidate = self.installed_restart_candidate()?;
let files = candidate
.as_ref()
.map(|_| ReleaseFiles::installed(&self.config))
.transpose()?;
preflight.prepare(candidate).await?;
if let Some(files) = files
&& files != ReleaseFiles::installed(&self.config)?
{
return Err(conflict("重启检查期间安装文件已变化,请重新确认"));
}
let message = if self.config.deployment_mode == "docker" {
"已安排进程内重启"
} else {
@@ -17,6 +17,37 @@ fn local_network() -> NetworkPolicy {
NetworkPolicy::new(&["127.0.0.0/8".into(), "::1/128".into()]).unwrap()
}
#[tokio::test]
async fn managed_https_http2_uses_authority_without_an_extra_host_header() {
use std::sync::Arc;
use crate::support::network::{FixedDns, Mode, ROOT, Server};
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
let url = format!("https://upstream.test:{}/query", server.address.port());
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
.unwrap()
.with_resolver(Arc::new(FixedDns(server.address.ip())));
let mut response = client
.open(
request(url.clone()),
None,
&local_network(),
Duration::from_secs(3),
)
.await
.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.body.read(64).await.unwrap().unwrap(), "secured");
let observation = server.next().await;
assert!(
observation
.head
.starts_with(&format!("GET {url} HTTP/2.0\r\n"))
);
assert_eq!(observation.sni.as_deref(), Some("upstream.test"));
}
#[tokio::test]
async fn managed_http_does_not_follow_redirects_or_fall_back_from_proxy() {
let server = MockServer::start().await;
@@ -1,13 +1,118 @@
use std::sync::Arc;
use gateway_admin::{
model::plugins::distribution::{RemotePluginLocation, SourceAuthentication},
model::{
AdminErrorKind,
plugins::distribution::{
DownloadPurpose, RemotePluginLocation, SourceAuthentication, SourceCredential,
SourceCredentialInfo,
},
},
ports::plugins::PluginDistribution,
};
use gateway_host::{
outbound::{HttpClient, NetworkPolicy},
plugin_distribution::HttpPluginDistribution,
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
use super::{credential, credential_with, digest, transport};
use crate::support::network::{FixedDns, Mode, ROOT, Server};
#[tokio::test]
async fn https_http2_download_preserves_scoped_credentials_and_digest_verification() {
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
let origin = format!("https://upstream.test:{}", server.address.port());
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
.unwrap()
.with_resolver(Arc::new(FixedDns(server.address.ip())));
let distribution = HttpPluginDistribution::with_transport(
&origin,
Arc::new(client),
NetworkPolicy::new(&["127.0.0.0/8".into()]).unwrap(),
)
.unwrap();
let result = distribution
.download(
RemotePluginLocation::Url {
url: format!("{origin}/private/plugin.tar.gz"),
sha256: Some(digest(b"secured")),
},
vec![SourceCredential {
info: SourceCredentialInfo {
id: "test-credential".into(),
name: "Test".into(),
origin,
path_prefix: "/private".into(),
purposes: vec![DownloadPurpose::Artifact],
},
authentication: SourceAuthentication::Bearer {
token: "test-token".into(),
},
}],
None,
)
.await
.unwrap();
assert_eq!(&*result.archive, b"secured");
assert!(
server
.next()
.await
.head
.contains("authorization: Bearer test-token\r\n")
);
}
#[tokio::test]
async fn source_failures_log_status_without_credentials_url_or_response_body() {
use std::io::{Read as _, Seek as _, SeekFrom};
use tracing::instrument::WithSubscriber as _;
let server = MockServer::start().await;
Mock::given(path("/private/plugin"))
.respond_with(ResponseTemplate::new(400).set_body_string("private upstream response"))
.expect(1)
.mount(&server)
.await;
let mut output = tempfile::tempfile().unwrap();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.without_time()
.json()
.with_writer(output.try_clone().unwrap())
.finish();
let error = transport(&server)
.download(
RemotePluginLocation::Url {
url: format!("{}/private/plugin", server.uri()),
sha256: None,
},
vec![credential(&server, "/private")],
None,
)
.with_subscriber(subscriber)
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::BadGateway);
assert_eq!(
error.to_string(),
"插件来源请求失败,请检查来源与下载授权后重试"
);
output.seek(SeekFrom::Start(0)).unwrap();
let mut log = String::new();
output.read_to_string(&mut log).unwrap();
let event: serde_json::Value = serde_json::from_str(&log).unwrap();
assert_eq!(event["fields"]["status"], 400);
for sensitive in ["test-token", "/private/plugin", "private upstream response"] {
assert!(!log.contains(sensitive));
}
}
#[tokio::test]
async fn url_preview_computes_digest_and_confirmation_rejects_replaced_content() {
@@ -36,6 +36,7 @@ pub struct Observation {
#[derive(Clone, Copy)]
pub enum Mode {
Origin,
Http2Origin,
HttpProxy,
Socks,
}
@@ -57,7 +58,7 @@ impl Server {
let listener = TcpListener::bind(bind).await.unwrap();
let address = listener.local_addr().unwrap();
let (sender, received) = mpsc::unbounded_channel();
let acceptor = tls.then(tls_acceptor);
let acceptor = tls.then(|| tls_acceptor(matches!(mode, Mode::Http2Origin)));
let task = tokio::spawn(async move {
let mut connections = JoinSet::new();
loop {
@@ -104,6 +105,9 @@ async fn serve(
mode: Mode,
observations: mpsc::UnboundedSender<Observation>,
) -> io::Result<()> {
if matches!(mode, Mode::Http2Origin) {
return serve_http2(stream, sni, observations).await;
}
if matches!(mode, Mode::Socks) {
return socks(stream, observations).await;
}
@@ -163,6 +167,50 @@ async fn serve(
Ok(())
}
async fn serve_http2(
stream: Box<dyn Socket>,
sni: Option<String>,
observations: mpsc::UnboundedSender<Observation>,
) -> io::Result<()> {
let service =
hyper::service::service_fn(move |request: http::Request<hyper::body::Incoming>| {
// 模拟 nginx 1.24:URI 已携带 :authority,额外的 Host 仍按重复头拒绝。
let status = if request.headers().contains_key(http::header::HOST) {
400
} else {
200
};
let mut head = format!(
"{} {} {:?}\r\n",
request.method(),
request.uri(),
request.version()
);
for (name, value) in request.headers() {
head.push_str(&format!("{name}: {}\r\n", value.to_str().unwrap()));
}
observations
.send(Observation {
head,
sni: sni.clone(),
target: None,
authentication: None,
})
.unwrap();
let response = http::Response::builder()
.status(status)
.body(http_body_util::Full::new(bytes::Bytes::from_static(
b"secured",
)))
.unwrap();
async { Ok::<_, std::convert::Infallible>(response) }
});
hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection(hyper_util::rt::TokioIo::new(stream), service)
.await
.map_err(io::Error::other)
}
async fn read_head(stream: &mut Box<dyn Socket>) -> io::Result<String> {
let mut bytes = Vec::new();
while !bytes.ends_with(b"\r\n\r\n") {
@@ -221,12 +269,12 @@ async fn socks(
Ok(())
}
fn tls_acceptor() -> TlsAcceptor {
fn tls_acceptor(http2: bool) -> TlsAcceptor {
use rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
};
let config = ServerConfig::builder_with_provider(Arc::new(
let mut config = ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()
@@ -241,5 +289,8 @@ fn tls_acceptor() -> TlsAcceptor {
)),
)
.unwrap();
if http2 {
config.alpn_protocols = vec![b"h2".to_vec()];
}
TlsAcceptor::from(Arc::new(config))
}
@@ -32,10 +32,18 @@ use tar::{Builder, EntryType, Header};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const OLD_RELEASE_MANIFEST: &str = r#"{"gateway_version":"1.0.0"}"#;
const TARGET_VERSION: &str = "1.9.9";
const CROSS_MAJOR_VERSION: &str = "2.0.0";
struct AllowingUpdatePreflight;
#[async_trait::async_trait]
impl gateway_admin::ports::system::SystemRestartPreflight for AllowingUpdatePreflight {
async fn prepare(&self, _: Option<SystemUpdateCandidate>) -> Result<(), SystemOperationError> {
Ok(())
}
}
#[async_trait::async_trait]
impl SystemUpdatePreflight for AllowingUpdatePreflight {
@@ -107,7 +115,10 @@ impl SystemUpdatePreflight for ChangingRollbackRevisionPreflight {
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
assert_eq!(candidate.target_version, "1.0.0");
assert_eq!(candidate.release_manifest.as_ref(), b"old-manifest");
assert_eq!(
candidate.release_manifest.as_ref(),
OLD_RELEASE_MANIFEST.as_bytes()
);
Ok(Revision::new(11).expect("revision"))
}
@@ -135,7 +146,10 @@ impl SystemUpdatePreflight for BlockingRollbackPreflight {
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
assert_eq!(candidate.target_version, "1.0.0");
assert_eq!(candidate.release_manifest.as_ref(), b"old-manifest");
assert_eq!(
candidate.release_manifest.as_ref(),
OLD_RELEASE_MANIFEST.as_bytes()
);
Ok(Revision::new(13).expect("revision"))
}
@@ -157,7 +171,12 @@ async fn restart_should_not_shutdown_when_replacement_spawn_fails() {
let shutdown = CancellationToken::new();
let service = ProcessSystemOperations::new(shutdown.clone(), config);
assert!(service.restart().await.is_err());
assert!(
service
.restart(Arc::new(AllowingUpdatePreflight))
.await
.is_err()
);
assert!(!shutdown.is_cancelled());
}
@@ -169,7 +188,10 @@ async fn restart_should_request_process_restart_inside_docker() {
config.deployment_mode = "docker".to_owned();
let shutdown = CancellationToken::new();
let service = ProcessSystemOperations::new(shutdown.clone(), config);
service.restart().await.expect("restart accepted");
service
.restart(Arc::new(AllowingUpdatePreflight))
.await
.expect("restart accepted");
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
.await
@@ -188,7 +210,7 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
assert_eq!(
service
.restart()
.restart(Arc::new(AllowingUpdatePreflight))
.await
.expect("replacement scheduled")
.kind(),
@@ -259,7 +281,10 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n");
assert_eq!(std::env::current_exe().expect("renamed executable"), backup);
service.restart().await.expect("restart accepted");
service
.restart(Arc::new(AllowingUpdatePreflight))
.await
.expect("restart accepted");
let marker = fixture.root.path().join("replacement-version");
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.is_file() {
@@ -308,7 +333,10 @@ async fn restart_should_conflict_while_another_system_operation_is_running() {
.expect("update reaches release fetch while holding the operation lock")
.expect("connected signal");
let error = service.restart().await.expect_err("restart during update");
let error = service
.restart(Arc::new(AllowingUpdatePreflight))
.await
.expect_err("restart during update");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert!(!shutdown.is_cancelled());
@@ -319,7 +347,10 @@ async fn restart_should_conflict_while_another_system_operation_is_running() {
wait_for_update(&service).await.operation.status,
SystemOperationStatus::Failed
);
service.restart().await.expect("restart after lock release");
service
.restart(Arc::new(AllowingUpdatePreflight))
.await
.expect("restart after lock release");
}
#[tokio::test]
@@ -375,7 +406,7 @@ async fn rollback_should_restore_binary_web_and_version_state() {
);
assert_eq!(
fs::read(fixture.official().join("plugin-release-manifest.json")).expect("manifest"),
b"old-manifest"
OLD_RELEASE_MANIFEST.as_bytes()
);
assert_eq!(
fs::read(fixture.official().join("old-plugin.tar.gz")).expect("old plugin"),
@@ -441,7 +472,7 @@ async fn update_should_restore_all_files_when_plugin_revision_changes_during_swa
);
assert_eq!(
fs::read(fixture.official().join("plugin-release-manifest.json")).expect("manifest"),
b"old-manifest"
OLD_RELEASE_MANIFEST.as_bytes()
);
assert!(fixture.official().join("old-plugin.tar.gz").is_file());
assert!(!fixture.official().join("new-plugin.tar.gz").exists());
@@ -1651,7 +1682,7 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
);
assert_eq!(
fs::read(fixture.official().join("plugin-release-manifest.json")).expect("manifest"),
b"old-manifest"
OLD_RELEASE_MANIFEST.as_bytes()
);
assert!(fixture.official().join("old-plugin.tar.gz").is_file());
assert!(!fixture.official().join("new-plugin.tar.gz").exists());
@@ -1963,7 +1994,7 @@ impl Fixture {
fs::create_dir_all(fixture.official()).expect("official plugin dir");
fs::write(
fixture.official().join("plugin-release-manifest.json"),
"old-manifest",
OLD_RELEASE_MANIFEST,
)
.expect("manifest");
fs::write(fixture.official().join("old-plugin.tar.gz"), "old-plugin").expect("plugin");
@@ -2230,3 +2261,43 @@ fn release_response(version: &str, assets: Vec<serde_json::Value>) -> ResponseTe
});
ResponseTemplate::new(200).set_body_json(serde_json::json!([release]))
}
struct RejectingRestartPreflight;
#[async_trait::async_trait]
impl gateway_admin::ports::system::SystemRestartPreflight for RejectingRestartPreflight {
async fn prepare(
&self,
candidate: Option<SystemUpdateCandidate>,
) -> Result<(), SystemOperationError> {
assert_eq!(candidate.unwrap().target_version, "1.0.0");
Err(SystemOperationError::new(
SystemOperationErrorKind::Conflict,
"plugins need confirmation",
))
}
}
#[tokio::test]
async fn restart_checks_plugins_before_shutdown_and_releases_locks_on_rejection() {
let fixture = Fixture::new();
let mut config = fixture.config("http://127.0.0.1:1/repos");
config.self_restart_enabled = true;
config.deployment_mode = "docker".into();
let shutdown = CancellationToken::new();
let service = ProcessSystemOperations::new(shutdown.clone(), config);
assert!(
service
.restart(Arc::new(RejectingRestartPreflight))
.await
.is_err()
);
assert!(!shutdown.is_cancelled());
assert!(service.restart_candidate().await.unwrap().is_some());
service
.restart(Arc::new(AllowingUpdatePreflight))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
.await
.unwrap();
}
@@ -51,6 +51,9 @@ impl PluginPackageInspector for PackageInspector {
let package = ValidatedPackage::read(archive, expected_sha256.as_deref(), limits)
.map_err(inspection_error)?;
let manifest = package.manifest();
if !super::compatibility::supports(manifest)? {
return Err(AdminError::invalid("插件能力与当前宿主不兼容"));
}
let target = manifest
.package_for(&host_version, std::env::consts::OS, std::env::consts::ARCH)
.map_err(|_| AdminError::invalid("插件不支持当前网关版本或平台"))?;
@@ -106,7 +106,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() {
"client_key_id":"key_budget", "daily_limit_usd":"10", "weekly_limit_usd":"20",
"daily_used_usd":"3", "weekly_used_usd":"0",
"daily_resets_at_ms":before.budget.daily_resets_at.map(|time| chrono::DateTime::<chrono::Utc>::from(time).timestamp_millis()),
"weekly_resets_at_ms":before.budget.weekly_resets_at.map(|time| chrono::DateTime::<chrono::Utc>::from(time).timestamp_millis()),
"weekly_resets_at_ms":null,
});
assert_eq!(results[6], expected);
assert_eq!(results[7], json!({"client_key_id":"key_budget"}));
@@ -126,10 +126,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() {
1
);
assert_eq!(after.budget.daily_resets_at, before.budget.daily_resets_at);
assert_eq!(
after.budget.weekly_resets_at,
before.budget.weekly_resets_at
);
assert_eq!(after.budget.weekly_resets_at, None);
assert_eq!(audits.len(), 1);
assert!(audits[0].starts_with(&format!("plugin:{}:scope:", view.target.instance_id)));
}
@@ -644,8 +644,24 @@ impl Environment {
}
async fn create_with_store_mode(command_line: bool) -> Option<Self> {
let database = std::env::var("CPR_PLUGIN_TEST_DATABASE_URL").ok()?;
let redis = std::env::var("CPR_PLUGIN_TEST_REDIS_URL").expect("isolated plugin Redis URL");
// 插件专用服务优先;CI 的标准测试服务同样通过随机 schema 隔离数据库。
let (database, redis) = if let Ok(database) = std::env::var("CPR_PLUGIN_TEST_DATABASE_URL")
{
(
database,
std::env::var("CPR_PLUGIN_TEST_REDIS_URL").expect("isolated plugin Redis URL"),
)
} else {
let database = std::env::var("CPR_TEST_DATABASE_URL").ok();
assert!(
database.is_some() || std::env::var_os("CI").is_none(),
"CI requires plugin or standard test services"
);
(
database?,
std::env::var("CPR_TEST_REDIS_URL").expect("isolated test Redis URL"),
)
};
let schema = format!("cpr_plugin_{}", uuid::Uuid::new_v4().simple());
let admin = PgPoolOptions::new()
.max_connections(1)
@@ -849,7 +865,10 @@ impl SystemOperations for UnusedAdminRuntime {
Err(unused_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn restart(
&self,
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unused_system())
}
}
@@ -178,7 +178,7 @@ Key 明文仍通过宿主管理面查看,插件模型调用使用返回的 Key
相同值再次赋值不产生新 revision 或审计;并发更新按事务顺序生效,同一字段由后提交的值覆盖,接口不提供调用去重或比较交换。
将非零上限降低到已用金额及以下会拒绝后续准入,提高限额可恢复准入,在途请求仍按原生规则完成和结算
重置只清零所选周期,保留额度上限、密钥、窗口到期时间和历史费用事件;未使用的 Key 不因此开启窗口。
重置清零所选周期并清除到期时间,保留额度上限、密钥和历史费用事件;所选窗口下次使用时按原生规则重新开启。
费用按完成时间归属,重置前完成但延迟落盘的费用不会重新扣入已重置周期,重置后完成的在途请求仍正常扣额。
实例版本复验、清零和审计在同一事务提交,不推进配置 revision。每次调用均执行新重置,可能清掉两次调用之间的新消费
@@ -186,6 +186,12 @@ SDK 不自动重试。超时或断连不能证明写入未提交;重试上限
不存在的 Key 返回 `rejected`;写入事务复验发现实例停用或版本变化时返回 `conflict`,
非法输入返回 `invalid_input`。账号关联、预算分配和重置触发由插件决定,宿主不自动串联上述接口
插件的外部周期、截止时间和同步进度保存在自身 `host.state.*` 中,并通过自己的管理页面展示;
这些数据不会改写宿主日/周窗口及其显示。插件可在 `request` 中间件中按自己的策略提前拒绝请求,
在确认新周期后调用预算重置或上限修改接口;调用仍遵循上述原生语义,宿主窗口继续按自身日期规则滚动。
私有状态写入与预算操作不是一个事务,不能把私有状态标记当作预算操作已提交的证明;结果未知时须按上述合同处理。
停用插件会停止其后续拦截与同步,已执行的额度修改或重置不会自动撤销
### Key、模型与模型调用
模型调用方法:
@@ -53,7 +53,7 @@ impl HostClient {
serde_json::from_value(reply.result).map_err(|_| invalid())
}
/// 清零指定周期,保留限额和到期时间;结果未知时不能盲目重试。
/// 清零并关闭指定周期,保留限额,下次使用时重新开启;结果未知时不能盲目重试。
///
/// # Errors
/// 实例过期、Key 不存在、宿主写入失败时返回错误。
@@ -78,13 +78,15 @@ async fn reset_client_key_budget_in_transaction(
ClientKeyBudgetPeriod::Weekly | ClientKeyBudgetPeriod::All
);
let reset_at = Utc::now();
// 推进计费起点,避免重置前完成、稍后落盘的费用重新扣额;未使用的 Key 不开启窗口。
// 起止时间收拢到重置边界,窗口保持未开启,同时排除重置前完成的迟到费用。
sqlx::query(
"update client_key_budget_windows set
daily_used_usd = case when $2 then 0 else daily_used_usd end,
daily_start = case when $2 and daily_end > $4 then $4 else daily_start end,
daily_start = case when $2 then $4 else daily_start end,
daily_end = case when $2 then $4 else daily_end end,
weekly_used_usd = case when $3 then 0 else weekly_used_usd end,
weekly_start = case when $3 and weekly_end > $4 then $4 else weekly_start end
weekly_start = case when $3 then $4 else weekly_start end,
weekly_end = case when $3 then $4 else weekly_end end
where client_api_key_id = $1",
)
.bind(command.id.as_str())
@@ -96,10 +98,18 @@ async fn reset_client_key_budget_in_transaction(
.map_err(|_| postgres_unavailable("reset client budget"))?;
let mut fields = Vec::new();
if daily {
fields.extend(["daily_used_usd".to_owned(), "daily_start".to_owned()]);
fields.extend([
"daily_used_usd".to_owned(),
"daily_start".to_owned(),
"daily_end".to_owned(),
]);
}
if weekly {
fields.extend(["weekly_used_usd".to_owned(), "weekly_start".to_owned()]);
fields.extend([
"weekly_used_usd".to_owned(),
"weekly_start".to_owned(),
"weekly_end".to_owned(),
]);
}
super::append_admin_audit_event_in_transaction(
tx,
@@ -174,7 +184,7 @@ impl PgClientBudgetStore {
.map_err(|_| unavailable())?,
};
let now = Utc::now();
advance_windows(&mut tx, key_id.as_str(), now)
advance_windows(&mut tx, key_id.as_str(), now, now)
.await
.map_err(|_| unavailable())?;
if limits.is_limited() {
@@ -243,7 +253,7 @@ async fn settle_in_transaction(
key: &str,
charge: &ClientBudgetCharge,
) -> Result<(), sqlx::Error> {
advance_windows(tx, key, Utc::now()).await?;
let completed_at = DateTime::<Utc>::from(charge.completed_at);
// 仅在请求结束时写入费用;请求 ID 冲突时不重复累计。
let changed = sqlx::query(
"insert into client_key_charge_events (request_id, client_api_key_id, amount_usd, completed_at)
@@ -253,16 +263,17 @@ async fn settle_in_transaction(
.bind(charge.request_id.as_str())
.bind(key)
.bind(charge.amount_usd.canonical())
.bind(DateTime::<Utc>::from(charge.completed_at))
.bind(completed_at)
.execute(&mut **tx)
.await?
.rows_affected();
if changed == 1 {
advance_windows(tx, key, Utc::now(), completed_at).await?;
sqlx::query("update client_key_budget_windows set
daily_used_usd = daily_used_usd + case when $3 >= daily_start and $3 < daily_end then $2::text::numeric else 0 end,
weekly_used_usd = weekly_used_usd + case when $3 >= weekly_start and $3 < weekly_end then $2::text::numeric else 0 end
where client_api_key_id = $1")
.bind(key).bind(charge.amount_usd.canonical()).bind(DateTime::<Utc>::from(charge.completed_at))
.bind(key).bind(charge.amount_usd.canonical()).bind(completed_at)
.execute(&mut **tx).await?;
}
Ok(())
@@ -291,19 +302,21 @@ async fn advance_windows(
tx: &mut Transaction<'_, Postgres>,
key: &str,
now: DateTime<Utc>,
used_at: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
// 到期日仍按北京时间零点计算,计费起点不能退回重置前;迟到费用不启动新窗口。
sqlx::query("insert into client_key_budget_windows
(client_api_key_id, daily_start, daily_end, weekly_start, weekly_end)
select $1, day, day + interval '24 hours', day, day + interval '168 hours'
from (select date_trunc('day', $2::timestamptz at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai' as day) d
on conflict (client_api_key_id) do update set
daily_start = case when client_key_budget_windows.daily_end <= $2 then excluded.daily_start else client_key_budget_windows.daily_start end,
daily_end = case when client_key_budget_windows.daily_end <= $2 then excluded.daily_end else client_key_budget_windows.daily_end end,
daily_used_usd = case when client_key_budget_windows.daily_end <= $2 then 0 else client_key_budget_windows.daily_used_usd end,
weekly_start = case when client_key_budget_windows.weekly_end <= $2 then excluded.weekly_start else client_key_budget_windows.weekly_start end,
weekly_end = case when client_key_budget_windows.weekly_end <= $2 then excluded.weekly_end else client_key_budget_windows.weekly_end end,
weekly_used_usd = case when client_key_budget_windows.weekly_end <= $2 then 0 else client_key_budget_windows.weekly_used_usd end")
.bind(key).bind(now).execute(&mut **tx).await?;
daily_start = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then greatest(excluded.daily_start, client_key_budget_windows.daily_start) else client_key_budget_windows.daily_start end,
daily_end = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then excluded.daily_end else client_key_budget_windows.daily_end end,
daily_used_usd = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then 0 else client_key_budget_windows.daily_used_usd end,
weekly_start = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then greatest(excluded.weekly_start, client_key_budget_windows.weekly_start) else client_key_budget_windows.weekly_start end,
weekly_end = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then excluded.weekly_end else client_key_budget_windows.weekly_end end,
weekly_used_usd = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then 0 else client_key_budget_windows.weekly_used_usd end")
.bind(key).bind(now).bind(used_at).execute(&mut **tx).await?;
Ok(())
}
@@ -261,7 +261,7 @@ impl ObservabilityRepository for PgObservabilityRepository {
range: ObservabilityRange,
filter: UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>> {
) -> StoreResult<DiagnosticsObservation> {
self.query_budget
.run(
"load usage diagnostics",
@@ -441,18 +441,17 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
range: admin_observability::TimeRange,
filter: admin_observability::UsageFilter,
dimension: admin_observability::DiagnosticDimension,
) -> AdminStoreResult<Vec<admin_observability::DiagnosticObservation>> {
self.repository
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
let observation = self
.repository
.usage_diagnostics(
store_range(range)?,
store_usage_filter(filter),
store_diagnostic_dimension(dimension),
)
.await
.map_err(observability_error)?
.into_iter()
.map(admin_diagnostic_observation)
.collect()
.map_err(observability_error)?;
admin_diagnostics_observation(observation)
}
async fn list_ops_errors(
@@ -459,6 +459,8 @@ pub(crate) fn admin_usage_list_record(
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,
@@ -688,12 +690,27 @@ pub(crate) fn admin_provider_observation(
}
}
pub(crate) fn admin_diagnostic_observation(
pub(crate) fn admin_diagnostics_observation(
observation: DiagnosticsObservation,
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
Ok(admin_observability::DiagnosticsObservation {
total_request_count: observation.total_request_count,
items: observation
.items
.into_iter()
.map(admin_diagnostic_observation)
.collect::<AdminStoreResult<_>>()?,
})
}
fn admin_diagnostic_observation(
observation: DiagnosticObservation,
) -> AdminStoreResult<admin_observability::DiagnosticObservation> {
Ok(admin_observability::DiagnosticObservation {
key: observation.key,
name: observation.name,
account_provider_kind: observation.account_provider_kind,
account_plan_type: observation.account_plan_type,
request_count: observation.request_count,
success_count: observation.success_count,
failure_count: observation.failure_count,
@@ -704,6 +721,7 @@ pub(crate) fn admin_diagnostic_observation(
first_token_p95_ms: observation.first_token_p95_ms,
non_completion_count: observation.non_completion_count,
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)?,
})
@@ -739,6 +757,8 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops
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,
@@ -791,6 +811,7 @@ pub(crate) fn usage_list_record_from_row(
provider_account_name: get(row, "provider_account_name")?,
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_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -936,6 +957,7 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
@@ -491,6 +491,7 @@ pub struct UsageListRecord {
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_notes: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -675,10 +676,18 @@ pub struct UsageOverview {
pub providers: Vec<ProviderObservation>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagnosticsObservation {
pub total_request_count: u64,
pub items: Vec<DiagnosticObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub account_provider_kind: Option<String>,
pub account_plan_type: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
@@ -689,6 +698,7 @@ pub struct DiagnosticObservation {
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
}
@@ -712,6 +722,7 @@ pub struct OpsErrorRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
pub provider_account_plan_type: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -793,6 +804,6 @@ pub trait ObservabilityRepository: Send + Sync {
range: ObservabilityRange,
filter: UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>>;
) -> StoreResult<DiagnosticsObservation>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> StoreResult<OpsErrorPage>;
}
@@ -10,6 +10,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
mr.endpoint, mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
account.plan_type as provider_account_plan_type,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport,
@@ -33,6 +34,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
'model_request:' || mr.id as stable_sort_id
from model_requests mr
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
left join provider_accounts account on account.id = mr.provider_account_ref
where true";
const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
@@ -42,6 +44,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
mr.endpoint, oe.provider_kind, oe.provider_account_ref,
oe.provider_account_name_snapshot as provider_account_name,
oe.provider_account_email_snapshot as provider_account_email,
account.plan_type as provider_account_plan_type,
oe.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
oe.upstream_model_id, null::text as upstream_transport, oe.failure_kind,
@@ -66,6 +69,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
from ops_events oe
left join model_requests mr on mr.id = oe.model_request_id
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
left join provider_accounts account on account.id = oe.provider_account_ref
where true";
pub(crate) async fn list_ops_errors(
@@ -129,6 +129,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
account.notes as provider_account_notes,
account.plan_type as provider_account_plan_type,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
@@ -384,7 +385,7 @@ pub(crate) async fn usage_diagnostics(
range: ObservabilityRange,
filter: &UsageRecordFilter,
dimension: DiagnosticDimension,
) -> StoreResult<Vec<DiagnosticObservation>> {
) -> StoreResult<DiagnosticsObservation> {
filter.validate()?;
let dimension_sql = diagnostic_dimension_sql(dimension);
let completed_usage = completed_usage_fact_predicate("mr");
@@ -424,6 +425,7 @@ pub(crate) async fn usage_diagnostics(
count(*) filter (where outcome in ('cancelled', 'incomplete'))::bigint
as non_completion_count,
coalesce(sum(greatest(attempt_count - 1, 0)), 0)::bigint as retry_count,
count(*) filter (where attempt_count > 1)::bigint as retried_request_count,
count(*) filter (
where is_completed_usage and cost_source = 'provider_reported'
)::bigint
@@ -443,6 +445,7 @@ pub(crate) async fn usage_diagnostics(
group by dimension_name, grouping sets ((), (cost_currency))
), selected_dimensions as (
select dimension_name,
sum(request_count) over ()::bigint as total_request_count,
row_number() over (order by request_count desc, dimension_name)
as sort_position
from aggregated
@@ -452,16 +455,25 @@ pub(crate) async fn usage_diagnostics(
statement.push_bind(DIAGNOSTIC_LIMIT);
statement.push(
")
select aggregated.*
select aggregated.*, selected.total_request_count,
account.provider_kind as account_provider_kind, account.plan_type as account_plan_type
from aggregated
join selected_dimensions selected using (dimension_name)
order by selected.sort_position, currency_grouping desc, cost_currency nulls last",
left join provider_accounts account on account.id = aggregated.dimension_name and ",
);
statement.push_bind(dimension == DiagnosticDimension::Account);
statement
.push(" order by selected.sort_position, currency_grouping desc, cost_currency nulls last");
let rows = statement
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("load usage diagnostics"))?;
let total_request_count = rows
.first()
.map(|row| unsigned(row, "total_request_count"))
.transpose()?
.unwrap_or_default();
let mut observations = Vec::with_capacity(DIAGNOSTIC_LIMIT as usize);
let mut costs = HashMap::<String, Vec<CurrencyCostTotal>>::new();
for row in &rows {
@@ -469,6 +481,8 @@ pub(crate) async fn usage_diagnostics(
1 => observations.push(DiagnosticObservation {
key: get(row, "dimension_name")?,
name: get(row, "dimension_name")?,
account_provider_kind: get(row, "account_provider_kind")?,
account_plan_type: get(row, "account_plan_type")?,
request_count: unsigned(row, "request_count")?,
success_count: unsigned(row, "success_count")?,
failure_count: unsigned(row, "failure_count")?,
@@ -479,6 +493,7 @@ pub(crate) async fn usage_diagnostics(
first_token_p95_ms: optional_unsigned(row, "first_token_p95_ms")?,
non_completion_count: unsigned(row, "non_completion_count")?,
retry_count: unsigned(row, "retry_count")?,
retried_request_count: unsigned(row, "retried_request_count")?,
cost_coverage: coverage_from_row(row)?,
costs: Vec::new(),
}),
@@ -523,7 +538,10 @@ pub(crate) async fn usage_diagnostics(
}
observation.costs = costs.remove(&observation.key).unwrap_or_default();
}
Ok(observations)
Ok(DiagnosticsObservation {
total_request_count,
items: observations,
})
}
pub(crate) async fn diagnostic_account_display_names(
@@ -96,6 +96,14 @@ impl PluginStore for PgPluginStore {
async fn configuration_versions(&self, id: &str) -> AdminStoreResult<Vec<String>> {
super::instances::configuration_versions(&self.pool, id).await
}
async fn disable_instances(
&self,
ids: &[String],
expected: Revision,
context: &MutationContext,
) -> AdminStoreResult<Revision> {
super::instances::disable(&self.pool, ids, expected, context).await
}
async fn save_instance(
&self,
instance: gateway_admin::model::plugins::instances::PluginInstance,
@@ -399,3 +399,42 @@ pub(super) async fn delete(
fn revision_from_i64(value: i64) -> AdminStoreResult<Revision> {
Revision::new(u64::try_from(value).map_err(|_| unavailable())?).map_err(|_| unavailable())
}
/// 所有停用共享全局 CAS 和审计事务,不能留下只停用一部分插件的状态。
pub(super) async fn disable(
pool: &PgPool,
ids: &[String],
expected: Revision,
context: &MutationContext,
) -> AdminStoreResult<Revision> {
let mut tx = pool.begin().await.map_err(|_| unavailable())?;
check_revision(&mut tx, expected).await?;
if ids.is_empty() {
return Ok(expected);
}
let revision = bump_config_revision_in_transaction(&mut tx)
.await
.map_err(|e| admin_store_error("plugin", e))?;
let committed = admin_revision(revision)?;
for id in ids {
let uuid = uuid::Uuid::parse_str(id).map_err(|_| conflict())?;
let digest: String = sqlx::query_scalar("update plugin_instances set enabled=false,revision=$2 where id=$1 and enabled=true returning artifact_sha256")
.bind(uuid).bind(i64::try_from(revision.get()).map_err(|_| unavailable())?)
.fetch_optional(&mut *tx).await.map_err(|_| unavailable())?.ok_or_else(conflict)?;
super::state::rebind_existing_configuration(&mut tx, id, &digest, committed).await?;
append_admin_audit_event_in_transaction(
&mut tx,
mutation_audit(
context,
MutationAuditOperation::PluginInstanceConfigure,
id,
vec!["enabled".into()],
),
revision,
)
.await
.map_err(|e| admin_store_error("plugin", e))?;
}
tx.commit().await.map_err(|_| unavailable())?;
Ok(committed)
}
@@ -75,7 +75,7 @@ fn context() -> MutationContext {
}
#[tokio::test]
async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_and_expiry() {
async fn manual_reset_clears_selected_windows_and_preserves_policy_and_history() {
let Some(database) = TestDatabase::create("budget_manual_reset").await else {
return;
};
@@ -108,14 +108,25 @@ async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_a
)
.await
.unwrap();
// 同一费用重试仍由事件 ID 去重,不因清零而被再次累计。
store.settle(billed).await.unwrap();
let after = status(&database, key).await;
assert_eq!(after.daily_used_usd.canonical(), daily);
assert_eq!(after.weekly_used_usd.canonical(), weekly);
assert_eq!(after.limits, before.limits);
assert_eq!(after.daily_resets_at, before.daily_resets_at);
assert_eq!(after.weekly_resets_at, before.weekly_resets_at);
assert_eq!(
after.daily_resets_at,
(period == ClientKeyBudgetPeriod::Weekly)
.then_some(before.daily_resets_at)
.flatten()
);
assert_eq!(
after.weekly_resets_at,
(period == ClientKeyBudgetPeriod::Daily)
.then_some(before.weekly_resets_at)
.flatten()
);
// 同一费用重试既不重复扣额,也不能重新开启已重置的窗口。
store.settle(billed).await.unwrap();
assert_eq!(status(&database, key).await, after);
assert_eq!(
store.admit(key_id(key)).await.is_ok(),
period == ClientKeyBudgetPeriod::All
@@ -130,6 +141,118 @@ async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_a
database.close().await;
}
#[tokio::test]
async fn manual_reset_restarts_selected_windows_only_after_new_usage() {
let Some(database) = TestDatabase::create("budget_reset_restart").await else {
return;
};
let store = PgClientBudgetStore::new(database.pool.clone());
let admin = PgAdminClientKeyStore::new(database.pool.clone());
for (key, period) in [
("daily", ClientKeyBudgetPeriod::Daily),
("weekly", ClientKeyBudgetPeriod::Weekly),
("all", ClientKeyBudgetPeriod::All),
] {
seed(&database, key, "10", "20").await;
store.admit(key_id(key)).await.unwrap();
store.settle(charge(key, key, "1")).await.unwrap();
sqlx::query("update client_key_budget_windows set weekly_start = weekly_start - interval '3 days', weekly_end = weekly_end - interval '3 days' where client_api_key_id = $1")
.bind(key).execute(&database.pool).await.unwrap();
let before = status(&database, key).await;
let delayed = charge(key, &format!("{key}-delayed"), "0.5");
let old = charge(key, &format!("{key}-old"), "0.7");
admin
.reset_client_key_budget(
ResetClientKeyBudget {
id: key_id(key),
period,
},
ClientKeyBudgetMutationOrigin::Admin,
&context(),
)
.await
.unwrap();
let reset = status(&database, key).await;
// 重置前完成的迟到费用保留历史,但不能开启窗口或重新扣入所选周期。
store.settle(delayed).await.unwrap();
let delayed = status(&database, key).await;
assert_eq!(delayed.daily_resets_at, reset.daily_resets_at);
assert_eq!(delayed.weekly_resets_at, reset.weekly_resets_at);
assert_eq!(
delayed.daily_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Weekly {
"1.5"
} else {
"0"
}
);
assert_eq!(
delayed.weekly_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Daily {
"1.5"
} else {
"0"
}
);
store.admit(key_id(key)).await.unwrap();
let restarted = status(&database, key).await;
assert_eq!(restarted.daily_resets_at, before.daily_resets_at);
if period == ClientKeyBudgetPeriod::Daily {
assert_eq!(restarted.weekly_resets_at, before.weekly_resets_at);
} else {
assert_eq!(
restarted.weekly_resets_at.unwrap(),
restarted.daily_resets_at.unwrap() + std::time::Duration::from_secs(6 * 86400)
);
assert_ne!(restarted.weekly_resets_at, before.weekly_resets_at);
}
// 开启窗口后仍保留重置边界,另一笔重置前的迟到费用也不能重新扣额。
store.settle(old).await.unwrap();
let after_old = status(&database, key).await;
assert_eq!(after_old.daily_resets_at, restarted.daily_resets_at);
assert_eq!(after_old.weekly_resets_at, restarted.weekly_resets_at);
assert_eq!(
after_old.daily_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Weekly {
"2.2"
} else {
"0"
}
);
assert_eq!(
after_old.weekly_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Daily {
"2.2"
} else {
"0"
}
);
store
.settle(charge(key, &format!("{key}-new"), "0.25"))
.await
.unwrap();
let used = status(&database, key).await;
assert_eq!(
used.daily_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Weekly {
"2.45"
} else {
"0.25"
}
);
assert_eq!(
used.weekly_used_usd.canonical(),
if period == ClientKeyBudgetPeriod::Daily {
"2.45"
} else {
"0.25"
}
);
}
database.close().await;
}
#[tokio::test]
async fn manual_reset_excludes_old_completions_but_counts_inflight_requests_after_reset() {
let Some(database) = TestDatabase::create("budget_reset_late").await else {
@@ -648,7 +771,7 @@ async fn plugin_resets_share_the_native_ledger_and_each_call_resets_again() {
assert_eq!(after.weekly_used_usd.canonical(), "0");
assert_eq!(after.limits, before.limits);
assert_eq!(after.daily_resets_at, before.daily_resets_at);
assert_eq!(after.weekly_resets_at, before.weekly_resets_at);
assert!(after.weekly_resets_at.is_none());
budgets.settle(charge("key", "after", "2")).await.unwrap();
assert_eq!(
@@ -666,6 +789,7 @@ async fn plugin_resets_share_the_native_ledger_and_each_call_resets_again() {
let after = status(&database, "key").await;
assert_eq!(after.daily_used_usd.canonical(), "5");
assert_eq!(after.weekly_used_usd.canonical(), "0");
assert!(after.weekly_resets_at.is_none());
let (audits, charges, current_revision): (i64, i64, i64) = sqlx::query_as(
"select (select count(*) from admin_audit_events where action='reset_budget' and config_revision is null),
(select count(*) from client_key_charge_events),
@@ -160,7 +160,7 @@ async fn usage_page_should_always_return_total() {
}
#[tokio::test]
async fn usage_list_should_resolve_current_notes_by_account_id() {
async fn usage_list_and_diagnostics_should_resolve_current_account_metadata_by_id() {
let Some(database) = TestDatabase::create("usage_account_notes").await else {
return;
};
@@ -197,12 +197,20 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
.expect("admin observability range");
let store = admin_observability_store(&database.pool);
for notes in [None, Some("个人主号"), Some("个人备用号"), None] {
sqlx::query("update provider_accounts set notes = $1 where id = 'acct_observe'")
.bind(notes)
.execute(&database.pool)
.await
.expect("update current account notes");
for (notes, plan) in [
(None, Some("pro")),
(Some("个人主号"), Some("plus")),
(Some("个人备用号"), Some("pro")),
(None, None),
] {
sqlx::query(
"update provider_accounts set notes = $1, plan_type = $2 where id = 'acct_observe'",
)
.bind(notes)
.bind(plan)
.execute(&database.pool)
.await
.expect("update current account notes");
let page = store
.list_usage_records(admin_observability::UsageQuery {
range,
@@ -230,6 +238,36 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
);
assert_eq!(personal.provider_account_email, team.provider_account_email);
assert_eq!(personal.provider_account_notes.as_deref(), notes);
assert_eq!(personal.provider_account_plan_type.as_deref(), plan);
assert_eq!(team.provider_account_plan_type.as_deref(), Some("team"));
let diagnostics = store
.usage_diagnostics(
range,
admin_observability::UsageFilter::default(),
admin_observability::DiagnosticDimension::Account,
)
.await
.expect("account plans");
assert_eq!(
diagnostics
.items
.iter()
.find(|item| item.key == "acct_observe")
.expect("personal diagnostics")
.account_plan_type
.as_deref(),
plan
);
assert_eq!(
diagnostics
.items
.iter()
.find(|item| item.key == "acct_team")
.expect("team diagnostics")
.account_plan_type
.as_deref(),
Some("team")
);
assert_eq!(team.provider_account_notes.as_deref(), Some("团队工作区"));
}
@@ -274,6 +312,19 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
Some("account@example.invalid")
);
assert_eq!(page.items[0].provider_account_notes, None);
assert_eq!(page.items[0].provider_account_plan_type, None);
let diagnostics = store
.usage_diagnostics(
range,
admin_observability::UsageFilter {
provider_account_ref: Some("acct_team".to_owned()),
..Default::default()
},
admin_observability::DiagnosticDimension::Account,
)
.await
.expect("deleted account diagnostics");
assert_eq!(diagnostics.items[0].account_plan_type, None);
database.close().await;
}
@@ -602,6 +653,114 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() {
database.close().await;
}
#[tokio::test]
async fn ops_errors_should_use_each_event_accounts_current_subscription() {
let Some(database) = TestDatabase::create("ops_account_subscription").await else {
return;
};
let now = Utc::now();
seed_observability_facts(&database.pool, now)
.await
.expect("seed observability facts");
sqlx::query(
"insert into provider_accounts (
id, provider_kind, name, email, upstream_user_id, upstream_account_id,
plan_type, authentication_kind, provider_credentials_json,
credential_revision, credential_observed_at, has_refresh_token,
created_at, updated_at
) select 'acct_retry', provider_kind, name, email, upstream_user_id,
'retry-workspace', 'team', authentication_kind,
provider_credentials_json, credential_revision, credential_observed_at,
has_refresh_token, created_at, updated_at
from provider_accounts where id = 'acct_observe'",
)
.execute(&database.pool)
.await
.expect("seed same-email retry account");
sqlx::query(
"update ops_events set provider_account_id = 'acct_retry',
provider_account_ref = 'acct_retry'
where id = 'ops_observe_retry'",
)
.execute(&database.pool)
.await
.expect("assign retry event to its own account");
let query = admin_observability::OpsErrorQuery {
range: admin_observability::TimeRange::new(
now - TimeDelta::hours(1),
now + TimeDelta::hours(1),
)
.expect("ops range"),
filter: admin_observability::OpsErrorFilter::default(),
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
};
let store = admin_observability_store(&database.pool);
for plan in [Some("pro"), Some("plus"), None] {
sqlx::query("update provider_accounts set plan_type = $1 where id = 'acct_observe'")
.bind(plan)
.execute(&database.pool)
.await
.expect("update current subscription");
let page = store
.list_ops_errors(query.clone())
.await
.expect("ops page");
assert_eq!(page.total, 2);
assert_eq!(page.items.len(), 2);
let request = page
.items
.iter()
.find(|error| error.source == "model_request")
.expect("request error");
let event = page
.items
.iter()
.find(|error| error.source == "ops_event")
.expect("retry event");
assert_eq!(request.provider_account_email, event.provider_account_email);
assert_eq!(request.provider_account_plan_type.as_deref(), plan);
assert_eq!(event.provider_account_plan_type.as_deref(), Some("team"));
}
sqlx::query(
"update ops_events set model_request_id = null, attempt_index = null
where id = 'ops_observe_retry'",
)
.execute(&database.pool)
.await
.expect("detach event from request");
let page = store
.list_ops_errors(query.clone())
.await
.expect("ops page");
assert_eq!(
page.items
.iter()
.find(|error| error.source == "ops_event")
.expect("standalone event")
.provider_account_plan_type
.as_deref(),
Some("team")
);
sqlx::query("delete from provider_accounts where id = 'acct_retry'")
.execute(&database.pool)
.await
.expect("delete retry account");
let page = store
.list_ops_errors(query)
.await
.expect("deleted account history");
assert_eq!(page.total, 2);
let event = page
.items
.iter()
.find(|error| error.source == "ops_event")
.expect("deleted account event");
assert_eq!(event.provider_account_ref.as_deref(), Some("acct_retry"));
assert_eq!(event.provider_account_plan_type, None);
database.close().await;
}
#[tokio::test]
async fn ops_should_include_incomplete_upstream_errors() {
let Some(database) = TestDatabase::create("ops_incomplete_error").await else {
@@ -765,7 +924,8 @@ async fn recovered_continuation_failure_should_be_visible_in_ops_but_hidden_from
DiagnosticDimension::Account,
)
.await
.expect("diagnostics without recovered intermediates");
.expect("diagnostics without recovered intermediates")
.items;
assert_eq!(diagnostics[0].request_count, 2);
assert_eq!(diagnostics[0].failure_count, 0);
@@ -1358,7 +1518,8 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
admin_observability::DiagnosticDimension::Account,
)
.await
.expect("admin diagnostics");
.expect("admin diagnostics")
.items;
assert_eq!(diagnostics[0].key, "acct_observe");
assert_eq!(diagnostics[0].name, "account@example.invalid");
assert_eq!(diagnostics[0].cost_coverage.provider_reported_count, 1);
@@ -1775,7 +1936,8 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
DiagnosticDimension::Account,
)
.await
.expect("usage diagnostics");
.expect("usage diagnostics")
.items;
assert_eq!(diagnostics[0].key, "acct_observe");
assert_eq!(diagnostics[0].name, "account@example.invalid");
assert_eq!(diagnostics[0].request_count, 3);
@@ -786,3 +786,86 @@ async fn version_settings_survive_upgrade_and_disabled_edits_and_share_the_trans
assert!(store.configuration_versions(&id).await.unwrap().is_empty());
database.close().await;
}
#[tokio::test]
async fn confirmed_disable_is_atomic_and_preserves_plugin_data() {
let Some(database) = TestDatabase::create("confirmed_disable").await else {
return;
};
initialize_revision(&database).await;
let store = PgPluginStore::new(database.pool.clone());
let installed = store
.install_artifact(
artifact('a', &["linux-x86_64"]),
PluginSource::Upload,
&context(),
)
.await
.unwrap();
let accepted = store
.accept_artifact(&installed.artifact.metadata.sha256, &context())
.await
.unwrap();
let mut expected = accepted.config_revision;
let mut ids = Vec::new();
for name in ["First", "Second", "Compatible"] {
let mut candidate = instance(installed.artifact.metadata.sha256.clone());
candidate.name = name.into();
candidate.enabled = true;
let saved = store
.save_instance(candidate, expected, &context())
.await
.unwrap();
expected = saved.config_revision;
ids.push(saved.instance.id);
}
let original = store.load_instances().await.unwrap();
for (targets, revision) in [
(
vec![ids[0].clone(), uuid::Uuid::now_v7().to_string()],
expected,
),
(
ids[..2].to_vec(),
Revision::new(expected.get() - 1).unwrap(),
),
] {
assert!(
store
.disable_instances(&targets, revision, &context())
.await
.is_err()
);
let unchanged = store.load_instances().await.unwrap();
assert_eq!(unchanged.config_revision, expected);
assert!(unchanged.instances.iter().all(|item| item.enabled));
}
let committed = store
.disable_instances(&ids[..2], expected, &context())
.await
.unwrap();
assert_eq!(committed.get(), expected.get() + 1);
let current = store.load_instances().await.unwrap();
for instance in current.instances {
let before = original
.instances
.iter()
.find(|item| item.id == instance.id)
.unwrap();
assert_eq!(instance.enabled, instance.id == ids[2]);
assert_eq!(instance.configuration, before.configuration);
assert_eq!(instance.bindings, before.bindings);
assert_eq!(
instance.secrets["token"].expose_secret(),
before.secrets["token"].expose_secret()
);
assert!(
store
.load_version_configuration(&instance.id, &instance.artifact_sha256)
.await
.unwrap()
.is_some()
);
}
database.close().await;
}
@@ -1,4 +1,5 @@
use chrono::{DateTime, TimeDelta, Utc};
use gateway_admin::ports::store::ObservabilityStore as _;
use gateway_store::postgres::{
DiagnosticDimension, ModelRequestAttemptStart, ModelRequestRepository, NewModelRequest,
ObservabilityPageSize, ObservabilityRange, ObservabilityRepository, OpsErrorFilter,
@@ -111,7 +112,8 @@ async fn completed_usage_projections_should_accept_statusless_websocket_but_reje
DiagnosticDimension::Account,
)
.await
.expect("load statusless usage diagnostics");
.expect("load statusless usage diagnostics")
.items;
assert_eq!(
(
diagnostics[0].request_count,
@@ -515,7 +517,8 @@ async fn diagnostics_should_group_same_email_accounts_by_stable_ref() {
DiagnosticDimension::Account,
)
.await
.expect("account diagnostics");
.expect("account diagnostics")
.items;
assert_eq!(diagnostics.len(), 2);
assert_eq!(
diagnostics
@@ -587,7 +590,8 @@ async fn diagnostics_should_fallback_to_name_then_ref_for_missing_snapshots() {
DiagnosticDimension::Account,
)
.await
.expect("account diagnostics");
.expect("account diagnostics")
.items;
let by_key = diagnostics
.iter()
.map(|item| (item.key.as_str(), item.name.as_str()))
@@ -652,7 +656,8 @@ async fn diagnostics_should_prefer_the_latest_non_null_email_snapshot() {
DiagnosticDimension::Account,
)
.await
.expect("account diagnostics");
.expect("account diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "acct_snapshot_history");
@@ -713,7 +718,8 @@ async fn failure_diagnostics_should_only_include_errored_requests() {
DiagnosticDimension::Failure,
)
.await
.expect("failure diagnostics");
.expect("failure diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "rate_limited");
assert_eq!(diagnostics[0].request_count, 1);
@@ -777,7 +783,8 @@ async fn model_diagnostics_should_exclude_provider_endpoints_without_a_model() {
DiagnosticDimension::Model,
)
.await
.expect("model diagnostics");
.expect("model diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "upstream-model");
@@ -838,6 +845,137 @@ async fn tokenless_provider_endpoints_should_remain_in_health_but_not_usage_reco
database.close().await;
}
#[tokio::test]
async fn diagnostics_should_keep_filtered_totals_before_limiting_groups() {
let Some(database) = TestDatabase::create("diagnostic_totals").await else {
return;
};
let now = Utc::now();
seed_account(&database.pool, "acct_totals", "Totals", None, "oauth", now).await;
let store = PgExecutionStore::new(database.pool.clone());
for index in 0..201 {
let id = format!("req_total_{index:03}");
let mut first_attempt = attempt(&id, 1, "acct_totals");
first_attempt.upstream_model_id = Some(format!("model_{:03}", index % 200));
store
.insert_model_request_with_first_attempt(new_request(&id, now), first_attempt)
.await
.expect("insert model group");
finalize_request(&database.pool, &id, now).await;
}
sqlx::query(
"update model_requests set cost_source = 'provider_reported', cost_amount = 1,
cost_currency = case when id = 'req_total_200' then 'EUR' else 'USD' end",
)
.execute(&database.pool)
.await
.expect("set currency groups");
let repository = observability_repository(&database.pool);
let result = repository
.usage_diagnostics(
range_around(now),
UsageRecordFilter::default(),
DiagnosticDimension::Model,
)
.await
.expect("limited groups");
assert_eq!(
(
result.total_request_count,
result.items.len(),
result
.items
.iter()
.map(|item| item.request_count)
.sum::<u64>()
),
(201, 100, 101)
);
assert_eq!(
(result.items[0].key.as_str(), result.items[0].costs.len()),
("model_000", 2)
);
let filtered = repository
.usage_diagnostics(
range_around(now),
UsageRecordFilter {
model: Some("model_000".to_owned()),
..UsageRecordFilter::default()
},
DiagnosticDimension::Model,
)
.await
.expect("filtered denominator");
assert_eq!((filtered.total_request_count, filtered.items.len()), (2, 1));
let empty = repository
.usage_diagnostics(
range_around(now),
UsageRecordFilter::default(),
DiagnosticDimension::Failure,
)
.await
.expect("empty error dimension");
assert_eq!((empty.total_request_count, empty.items.len()), (0, 0));
database.close().await;
}
#[tokio::test]
async fn diagnostics_should_distinguish_retry_attempts_from_retried_requests() {
let Some(database) = TestDatabase::create("diagnostic_retries").await else {
return;
};
let now = Utc::now();
seed_account(
&database.pool,
"acct_retries",
"Retries",
None,
"oauth",
now,
)
.await;
let store = PgExecutionStore::new(database.pool.clone());
for (index, attempts) in [0, 1, 1, 1, 1, 1, 3, 5].into_iter().enumerate() {
let id = format!("req_retry_{index}");
store
.insert_model_request_with_first_attempt(
new_request(&id, now),
attempt(&id, 1, "acct_retries"),
)
.await
.expect("insert retry request");
finalize_request(&database.pool, &id, now).await;
sqlx::query("update model_requests set attempt_count = $2 where id = $1")
.bind(id)
.bind(attempts)
.execute(&database.pool)
.await
.expect("set attempts");
}
let result = super::admin_observability_store(&database.pool)
.usage_diagnostics(
gateway_admin::model::observability::TimeRange::new(
now - TimeDelta::hours(1),
now + TimeDelta::hours(1),
)
.expect("range"),
gateway_admin::model::observability::UsageFilter::default(),
gateway_admin::model::observability::DiagnosticDimension::Model,
)
.await
.expect("retry metrics through admin adapter");
assert_eq!(
(
result.total_request_count,
result.items[0].retry_count,
result.items[0].retried_request_count
),
(8, 6, 2)
);
database.close().await;
}
#[tokio::test]
async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
let Some(database) = TestDatabase::create("snapshot_api_key_dimension").await else {
@@ -886,7 +1024,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
DiagnosticDimension::ApiKey,
)
.await
.expect("api key diagnostics");
.expect("api key diagnostics")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "key_diag");
assert_eq!(diagnostics[0].name, "My Key");
@@ -904,7 +1043,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
DiagnosticDimension::ApiKey,
)
.await
.expect("api key diagnostics after deletion");
.expect("api key diagnostics after deletion")
.items;
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].key, "key_diag");
assert_eq!(diagnostics[0].name, "key_diag");
@@ -228,7 +228,7 @@ struct PricingRule {
// 价格来源:https://developers.openai.com/api/docs/pricing。
// 只登记已核验的常规价格及服务档位;临时优惠不写入内置价目。
// 原有规则于 2026-09-09 核验,新增型号的核验日期见对应条目。
// 原有规则于 2026-09-09 核验,GPT-6.1 Sol 于 2026-09-29 核验,其余新增型号见对应条目。
// 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13,
// 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。
const PRICING_RULES: &[PricingRule] = &[
@@ -244,6 +244,16 @@ const PRICING_RULES: &[PricingRule] = &[
.with_long_flex(100_000, 375_000, 10_000)
.with_long_fast(400_000, 1_500_000, 40_000),
},
PricingRule {
model: "gpt-6.1-sol",
pricing: ModelPricing::new(20_000, 100_000, 1_000)
.with_cache_write(125)
.with_flex(10_000, 50_000, 500)
.with_fast(40_000, 200_000, 2_000)
.with_long(40_000, 150_000, 2_000)
.with_long_flex(20_000, 75_000, 1_000)
.with_long_fast(80_000, 300_000, 4_000),
},
// https://developers.openai.com/api/docs/models/gpt-6-sol,核验日期 2026-09-24。
PricingRule {
model: "gpt-6-sol",
@@ -864,7 +874,10 @@ fn reasoning_model(model: &str) -> bool {
let normalized = normalize_model_name(model);
let model = pricing_model_name(&normalized);
model.starts_with("gpt-5")
|| matches!(model, "gpt-6-astra" | "gpt-6-sol" | "gpt-6-luna")
|| matches!(
model,
"gpt-6-astra" | "gpt-6.1-sol" | "gpt-6-sol" | "gpt-6-luna"
)
|| model.starts_with("o1")
|| model.starts_with("o3")
|| model.starts_with("o4")
@@ -352,7 +352,14 @@ async fn copying_builtin_prices_keeps_cache_read_and_write_fallback_costs() {
.await
.unwrap();
let prices = bundle.admin_provider().pricing_catalog();
for model in ["gpt-4", "gpt-4o", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
for model in [
"gpt-4",
"gpt-4o",
"gpt-6-astra",
"gpt-6.1-sol",
"gpt-6-sol",
"gpt-6-luna",
] {
let usage = OpenAiBillingUsage::new(100, 10, 20, 15);
let inherited = openai_billing_breakdown(model, usage, None).unwrap();
let copied =
@@ -399,7 +399,7 @@ fn decoder_should_use_non_reasoning_preview_web_search_price() {
#[test]
fn gpt_6_decoders_should_use_reasoning_preview_web_search_price() {
let tools = vec![json!({ "type": "web_search_preview" })];
for model in ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
for model in ["gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"] {
let created = json!({
"type":"response.created",
"response":{"id":"resp_preview_search_cost","model":model}
@@ -80,6 +80,17 @@ fn astra_billing_should_preserve_components_across_tiers_and_context_boundary()
fn new_gpt_6_models_should_use_published_prices_across_tiers_and_context_boundary() {
// 每档依次核对每百万 Token 的输入、缓存读取、缓存写入和输出价格。
for (model, cases) in [
(
"gpt-6.1-sol",
[
(None, 272_000, ["2", "0.1", "2.5", "10"]),
(None, 272_001, ["4", "0.2", "5", "15"]),
(Some("flex"), 272_000, ["1", "0.05", "1.25", "5"]),
(Some("flex"), 272_001, ["2", "0.1", "2.5", "7.5"]),
(Some("fast"), 272_000, ["4", "0.2", "5", "20"]),
(Some("priority"), 272_001, ["8", "0.4", "10", "30"]),
],
),
(
"gpt-6-sol",
[
+2 -1
View File
@@ -573,7 +573,8 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
| `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 |
| 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 |
- 更新或回滚前,逐一检查启用实例的精确包与目标宿主是否兼容。制品缺失、不兼容或并发配置变更会阻止切换;失败或取消时恢复整组文件,不自动停用插件或接受其他制品
- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后批量停用并重启,保留包体、配置、密钥和私有数据,取消不修改插件状态
- 回滚仍检查启用插件与旧宿主的兼容性,不兼容时先停用对应插件。更新或回滚期间的并发配置变更会阻止切换,文件替换失败或取消时恢复整组文件
- 非空官方清单中的包经身份、平台和摘要校验后幂等导入;导入不确认信任、不创建配置、不启用,也不删除旧包。管理员确认信任后才执行默认配置流程;重复摘要保留首次安装出处
- `sealed` 是构建封口标记,不是密码学签名。普通上传、URL 或 GitHub 安装不能获得 `builtin` 身份,独立教学示例也不例外
+21 -10
View File
@@ -911,8 +911,6 @@ OAuth start 使用:
- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:本周期已记录用量加预计剩余量,
公式为 `(本周期已记录用量 + 样本用量 × (100 - usedPercent) / sampledPercent) × 目标窗口秒数 / 源窗口秒数`。
真实周期由上游额度重置边界定义,不按自然周/月累计;只有折算结果才乘以目标与源窗口的时长比
- `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算,
公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额
- `source`:源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、
额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及本周期累计
已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。
@@ -1130,8 +1128,8 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造
`dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)。
记账和限额比较保留完整精度。
日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。
手动重置仅清零所选周期的已用金额,保留限额上限、原到期时间和历史费用,返回 `{ id }`。
未使用或已过期的窗口不会因手动重置而重新开启。重置前完成但延迟结算的费用不再计入所选周期;
手动重置清零所选周期的已用金额并清除到期时间,保留限额上限和历史费用,返回 `{ id }`。
所选窗口的重置时间返回 `null`,下次使用时按新建 Key 的规则重新开启。重置前完成但延迟结算的费用不再计入所选周期,也不会开启窗口;
重置后完成的请求继续计费,包括重置时仍在进行的请求。操作保留管理员审计,不改变账号上游额度。
费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket
`response.create`;空闲连接不占名额,内部重试不重复占用。
@@ -1561,6 +1559,12 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
同请求量按维度标识稳定排列
`requestShare` 的分母为该维度筛选后、截取前的全部请求数;`failureClass` 只在带错误类型的请求内计算占比
`retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次
账号维度诊断项、使用记录列表和错误排查列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前订阅及展示名称,
按各记录的内部账号 ID 关联;订阅未知或账号已删除时为 `null`,不作为请求发生时的订阅快照
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。
备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属
@@ -1652,7 +1656,8 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier`
| `POST` | `/api/admin/system/update` | `{ targetVersion, channel? }` | 受理后台在线更新,返回 `202` |
| `GET` | `/api/admin/system/update/status` | 无 | 查询当前更新或回滚状态 |
| `POST` | `/api/admin/system/rollback` | 无 | 回滚到保留的上一版本 |
| `POST` | `/api/admin/system/restart` | 无 | 请求进程重启 |
| `GET` | `/api/admin/system/restart/check` | 无 | 只读检查重启目标与启用插件 |
| `POST` | `/api/admin/system/restart` | `{ confirmation? }` | 复核确认、停用不兼容插件并重启 |
在线更新遵循[版本命名与升级规则](../deploy/README.md#版本命名与升级规则)。版本接口的
`updateChannel` 由当前版本推导,取值为 `stable`、`alpha`、`beta`、`rc`、`exp`,无法识别时为 `unknown`。
@@ -1675,10 +1680,15 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier`
执行,结果通过 `/update/status` 的 `operation` 查询:`status` 为 `idle`、`running`、`succeeded` 或 `failed`,
终态包含 `finishedAt`,失败原因在 `error` 中。SSE 的 `operationId` 用于关联进度;终态事件发出前状态已落盘。
连接中断不取消已受理任务;响应丢失时先查询状态,不自动重复提交。打开更新页面时也会恢复最近一次任务。
下载并解包后,Admin 会以目标发行清单声明的宿主插件合同检查每个启用实例的精确制品;回滚则对备份发行清单
执行同一检查。两条路径都会在文件交换前后复核同一全局插件配置版本。任一实例不兼容、制品缺失、请求取消或
配置并发变化都会失败;已交换的二进制、Web 资源和官方插件目录会成组恢复。预检不会自动停用实例、切换插件
版本或接受其他制品
下载并解包后校验发行身份,插件不兼容不阻止安装;回滚仍要求启用插件与备份发行兼容。
两条路径在文件交换前后复核全局配置版本,文件替换失败或取消时成组恢复二进制、Web 资源和官方插件目录
详情响应的 `restartConfirmationSupported=true` 表示运行进程支持重启前确认。
`restart/check` 返回 `targetVersion`、`releaseManifestSha256`、`configRevision` 和 `incompatiblePlugins`,
每项包含 `instanceId`、`name`、`reason`,检查不修改插件状态。源码运行的目标版本与发行摘要为空,检查当前宿主兼容性。
存在不兼容插件时,客户端展示列表并取得确认后,将完整检查结果作为 `confirmation` 提交重启请求。
服务端在重启锁内重新检查,目标或配置变化返回 `40901`,须重新检查并确认;确认匹配才在单个事务中停用对应插件并发布配置。
没有不兼容插件时可省略确认,不切换插件版本或接受其他制品。停用保留设置、密钥、版本配置和私有数据
状态响应的 `currentVersion` 表示已安装文件的版本,运行中的版本仍以 `/version` 为准。
`needRestart=true` 表示已验证的安装文件尚未在当前进程生效,此时应调用重启接口,不能重复发起更新或切换通道。
@@ -1868,7 +1878,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
待停用配置必须仍启用且 revision 与确认时一致,否则返回 409,重新读取并确认后再提交
列表项包含 `id`、`name`、`artifactSha256`、`enabled`、`configurationRequired`、`configuration`、
`secretFields`、`bindings`、`revision`、`running`、`publishedRevision` 和 `runtime`。`configurationRequired`
`secretFields`、`bindings`、`revision`、`running`、`publishedRevision`、`runtime` 和 `compatibilityWarning`。
`compatibilityWarning` 为非空字符串时表示该固定制品无法在当前宿主启动,停用实例也返回原因,启用请求会拒绝。`configurationRequired`
由当前制品 schema 与已保存的普通/敏感配置实时派生,不写入数据库;它只表示仍缺少必填值。停用实例也不能保存
类型错误、非法 schema 或把敏感字段混入普通配置
+10 -5
View File
@@ -556,11 +556,13 @@ Key 的 RPM 在成功准入时才计数,金额限制在入队前及成功准
日金额、七天金额、并发和 RPM 按 Client Key 跨账号、跨 Provider 合计,零表示不限;修改限额不重置已用金额。
Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限额配置。
Admin 的手动重置复用同一账本与 Key 行锁,在一个事务中清零所选周期金额、推进计费起点并写入审计,
保留窗口到期时间与费用事件,不推进配置 revision。结算仍按完成时间判断归属,重置前完成的费用不会重新扣入已重置周期。
关闭所选窗口并保留费用事件,不推进配置 revision,下次使用时重新开启窗口。
结算仍按完成时间判断归属,重置前完成的费用不会重新扣入已重置周期或开启窗口。
插件预算回调通过 `PluginClientKeyAccess` 进入同一 `ClientKeyService` 和 `ClientKeyStore`,存储事务先复核插件实例版本,
再执行原生预算操作;不建立另一份预算状态。查询复用账本投影,不触发准入。局部更新金额上限不改用量或窗口,
仅在值变化时提交配置 revision、审计并发布;重置仍不推进配置版本。重复调用及结果未知的合同见
[SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算)
[SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算)。插件的外部周期和截止时间属于插件私有状态,展示在插件页面;
宿主预算接口和管理页面只表达原生窗口事实,不为插件周期修改滚动规则或重置语义
- 日窗口按北京时间零点划分;七天窗口从首次准入当天零点开始,到期后由下一次使用重新开启,不固定为周一
- 金额优先使用 Provider 上报的 USD,否则按现有模型价格估算;订阅账号的估算费用不代表上游订阅账单。
@@ -794,8 +796,10 @@ worker 复用连接测试探针执行真实上游调用,并按冻结代次处
### 在线更新与回滚
在线更新校验 Release host、大小、SHA-256 和归档路径,并只允许同一大版本内更新;更新与回滚的目标发行清单
还要通过全部启用插件的静态兼容检查,文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
在线更新校验 Release host、大小、SHA-256、归档路径和发行身份,并只允许同一大版本内更新。
下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。
Host 在重启操作锁内重新检查,Admin 仅在确认匹配时原子停用对应插件并发布配置,保留密钥和私有数据;取消不产生写入。
回滚仍要求全部启用插件与旧宿主兼容;更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
状态文件记录操作结果,SSE 终态在状态落盘后发送,Host 关闭与任务析构都必须收敛状态
@@ -846,7 +850,8 @@ RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --
其他检查与界面验证按 [贡献与审查](../CONTRIBUTING.md#验证) 执行
插件 Runtime 的真实子进程与持久化测试使用 `CPR_PLUGIN_TEST_DATABASE_URL` 和
`CPR_PLUGIN_TEST_REDIS_URL` 指向专用实例,密码及隔离要求与上述 Store 测试一致。
`CPR_PLUGIN_TEST_REDIS_URL` 指向专用实例;未提供插件专用变量时复用 `CPR_TEST_DATABASE_URL` 与 `CPR_TEST_REDIS_URL`。
密码及隔离要求与上述 Store 测试一致,CI 缺少服务配置时直接失败。
设置 `CPR_PLUGIN_TEST_LIVE_HTTP=1` 会额外请求 GitHub 公共 HTTPS API,验证受管出站链路;这不代表模型推理验收。
若测试环境使用 Fake-IP 或私网 DNS,需通过 `CPR_PLUGIN_TEST_LIVE_NETWORK_RANGES` 显式提供逗号分隔的
CIDR 授权。该选项只用于真实网络测试,默认为空,不改变生产网络策略或其他测试的授权
+3 -1
View File
@@ -117,7 +117,9 @@ flowchart LR
### 故障处理
宿主重启时逐个恢复已启用插件。不兼容、启动失败的实例显示异常并保留设置,管理员可重新启动、停用或切换版本;
系统更新后的重启检查会列出不兼容插件,管理员确认后停用并重启,保留设置、密钥和私有数据。
插件管理页用黄色警告三角显示不兼容原因,此类插件不能启动,安装兼容版本后可重新启用。
其他启动失败的实例显示异常并保留设置,管理员可重新启动、停用或切换版本;
其他插件、原生转发和管理端继续运行。运行中的插件进程退出时,也不会撤销整个宿主的运行配置。
命中故障插件绑定的请求仍遵循既有拒绝或委托策略;入口认证插件失败不会绕过认证
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.0",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.47.0",
+6 -6
View File
@@ -170,8 +170,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.43(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.3.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#v0.3.1
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -384,9 +384,9 @@ packages:
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522':
resolution: {gitHosted: true, integrity: sha512-I2h13qjmqABk4IviUXdiZXPU9o//qO6WGbBBvav9rXKXyEjfFXvwvz0ofa/4UWOmTRBhmcRFmEIE0qUTzHZThQ==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522}
version: 0.3.0
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01':
resolution: {gitHosted: true, integrity: sha512-TcVbsvwVd+19Ex+o0z3Q6XYx+k2p/lsAWFpCN704zujhz2qqBh9hEp8+aCsA/1kUhLASQki3sXFa5cmpYpTO2g==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01}
version: 0.3.1
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2783,7 +2783,7 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
-4
View File
@@ -190,10 +190,6 @@ export interface AccountQuotaForecast {
estimatedTokensDisplay: string
estimatedUsd: number | null
estimatedUsdDisplay: string
remainingTokens: number | null
remainingTokensDisplay: string
remainingUsd: number | null
remainingUsdDisplay: string
}
export interface AccountQuotaForecastResponse {
+1
View File
@@ -216,6 +216,7 @@ export interface PluginInstance {
artifactSha256: string
enabled: boolean
configurationRequired: boolean
compatibilityWarning: string | null
configuration: Record<string, unknown>
secretFields: string[]
bindings: PluginCapabilityBinding[]
+14 -1
View File
@@ -34,6 +34,7 @@ export interface SystemUpdateDetail {
notes: string | null
cached: boolean
updateSupported: boolean
restartConfirmationSupported?: boolean
unsupportedReason: string | null
warning: string | null
}
@@ -101,10 +102,22 @@ export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOp
})
}
export function restartSystem(options: RequestOptions = {}) {
export interface SystemRestartPlan {
targetVersion: string | null
releaseManifestSha256: string | null
configRevision: number
incompatiblePlugins: { instanceId: string, name: string, reason: string }[]
}
export function checkSystemRestart() {
return request<SystemRestartPlan>({ url: '/api/admin/system/restart/check', method: 'GET' })
}
export function restartSystem(confirmation?: SystemRestartPlan, options: RequestOptions = {}) {
return request<SystemRestartAccepted>({
url: '/api/admin/system/restart',
method: 'POST',
data: { confirmation },
...options,
})
}
+6
View File
@@ -83,6 +83,8 @@ export interface UsageListRecord {
accountEmail: string | null
accountName: string | null
accountNotes: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
route: string
model: string | null
requestedModel: string | null
@@ -276,6 +278,8 @@ export interface OpsError {
accountId: string | null
accountName: string | null
accountEmail: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
route: string
model: string | null
requestedModel: string | null
@@ -436,6 +440,8 @@ export interface UsageInsightsOverviewResponse {
export interface UsageDiagnosticItem {
key: string
name: string
accountPlanType: string | null
accountPlanTypeDisplay: string | null
requestCount: number
successCount: number
errorCount: number
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { SystemUpdateChannel, SystemUpdateDetail } from '@/api'
import type { SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail } from '@/api'
import { BaseButton, BaseConfirmModal, BaseMarkdown, BaseModal, BasePopover, BaseScrollbar, BaseSegmented, BaseSkeleton, toast } from '@codex-proxy/ui'
import {
@@ -12,9 +12,11 @@ import {
Power,
RefreshCw,
Terminal,
TriangleAlert,
} from '@lucide/vue'
import { storeToRefs } from 'pinia'
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import { checkSystemRestart } from '@/api'
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
import { formatDateTime, formatTime } from '@/utils/format'
import { errorMessage } from '@/utils/operation'
@@ -53,6 +55,9 @@ const updateConfirmOpen = shallowRef(false)
const updateConfirmInfo = shallowRef<SystemUpdateDetail | null>(null)
const updateConfirmPreviousTarget = shallowRef('')
const preparingUpdate = shallowRef(false)
const checkingRestart = shallowRef(false)
const restartConfirmOpen = shallowRef(false)
const restartPlan = shallowRef<SystemRestartPlan | null>(null)
const presentation = computed(() => resolveSystemUpdatePresentation({
version: version.value,
@@ -186,11 +191,42 @@ async function handleConfirmUpdate() {
}
async function handleRestart() {
if (checkingRestart.value || restarting.value)
return
checkingRestart.value = true
try {
await restartNow()
if (!updateInfo.value)
throw new Error('请等待系统更新信息加载完成')
// 文件已更新但旧进程仍在运行时,旧 API 继续使用安装前的兼容预检。
if (!updateInfo.value.restartConfirmationSupported) {
await restartNow()
return
}
restartPlan.value = await checkSystemRestart()
if (restartPlan.value.incompatiblePlugins.length) {
restartConfirmOpen.value = true
return
}
await restartNow(restartPlan.value)
}
catch (error: unknown) {
toast.error(errorMessage(error, '重启失败'))
toast.error(errorMessage(error, '重启前检查失败'))
}
finally {
checkingRestart.value = false
}
}
async function handleConfirmRestart() {
if (!restartPlan.value)
return
try {
await restartNow(restartPlan.value)
restartConfirmOpen.value = false
}
catch (error: unknown) {
restartConfirmOpen.value = false
toast.error(errorMessage(error, '重启失败,请重新检查'))
}
}
@@ -443,8 +479,8 @@ watch(
<BaseButton
v-if="needRestart"
variant="primary"
:loading="restarting"
:disabled="updating"
:loading="restarting || checkingRestart"
:disabled="loading || updating || restartConfirmOpen"
@click="handleRestart"
>
<template #icon>
@@ -467,6 +503,31 @@ watch(
</template>
</BaseModal>
<BaseConfirmModal
v-model="restartConfirmOpen"
title="发现不兼容插件"
description="配置与数据保留,兼容后可启用"
confirm-text="停用并重启"
:loading="restarting"
@confirm="handleConfirmRestart"
>
<div class="grid gap-4">
<div v-if="restartPlan?.targetVersion" class="flex items-center justify-between gap-3 text-cp-sm">
<span class="text-cp-text-secondary">重启后版本</span>
<span class="font-mono font-emphasis text-cp-text">v{{ restartPlan.targetVersion }}</span>
</div>
<ul class="m-0 max-h-64 list-none overflow-y-auto p-0" aria-label="不兼容插件">
<li v-for="plugin in restartPlan?.incompatiblePlugins" :key="plugin.instanceId" class="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
<TriangleAlert class="mt-0.5 size-4 shrink-0 text-cp-warning" aria-hidden="true" />
<div class="grid min-w-0 gap-1">
<strong class="wrap-anywhere text-cp-sm font-heavy text-cp-text">{{ plugin.name }}</strong>
<span class="wrap-anywhere text-cp-xs leading-relaxed font-normal text-cp-text-secondary">{{ plugin.reason }}</span>
</div>
</li>
</ul>
</div>
</BaseConfirmModal>
<BaseConfirmModal
v-model="updateConfirmOpen"
title="发现新的更新版本"
+3 -22
View File
@@ -15,14 +15,13 @@ import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
const uiStore = useUiStore()
const systemUpdateStore = useSystemUpdateStore()
const { sidebarCollapsed } = storeToRefs(uiStore)
const { loadedOnce, version } = storeToRefs(systemUpdateStore)
const { version } = storeToRefs(systemUpdateStore)
const { toggleSidebar } = uiStore
const route = useRoute()
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
const mobileSidebarOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
const systemUpdateOpen = shallowRef(false)
const systemUpdateOpening = shallowRef(false)
function openMobileSidebar() {
mobileSidebarOpen.value = true
@@ -32,24 +31,6 @@ function closeMobileSidebar() {
mobileSidebarOpen.value = false
}
async function openSystemUpdate() {
if (systemUpdateOpen.value || systemUpdateOpening.value)
return
systemUpdateOpening.value = true
try {
if (!loadedOnce.value)
await systemUpdateStore.loadSystem(false)
}
catch {
// 弹窗打开后由弹窗内的加载逻辑提示失败原因。
}
finally {
systemUpdateOpening.value = false
systemUpdateOpen.value = true
}
}
onMounted(() => {
void systemUpdateStore.loadVersion().catch(() => undefined)
})
@@ -75,7 +56,7 @@ watch(
:collapsed="sidebarCollapsed"
@toggle="toggleSidebar"
@open-about="aboutOpen = true"
@open-system-update="openSystemUpdate"
@open-system-update="systemUpdateOpen = true"
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
@@ -103,7 +84,7 @@ watch(
@close="closeMobileSidebar"
@navigate="closeMobileSidebar"
@open-about="aboutOpen = true"
@open-system-update="openSystemUpdate"
@open-system-update="systemUpdateOpen = true"
/>
</div>
</div>
+3 -6
View File
@@ -1,4 +1,4 @@
import type { SystemUpdateChannel, SystemUpdatePolicy, SystemUpdateStatus } from '@/api'
import type { SystemRestartPlan, SystemUpdateChannel, SystemUpdatePolicy, SystemUpdateStatus } from '@/api'
import { until, useEventSource, useTimeoutPoll } from '@vueuse/core'
import { delay } from 'es-toolkit'
import { defineStore } from 'pinia'
@@ -60,7 +60,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
const updateError = shallowRef('')
const statusAvailable = shallowRef(false)
const policy = shallowRef<SystemUpdatePolicy | null>(null)
const loadedOnce = shallowRef(false)
const updateLogs = ref<SystemUpdateEvent[]>([])
const updateStreaming = shallowRef(false)
const updateStreamError = shallowRef('')
@@ -282,7 +281,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
return
updateInfo.value = detail
policy.value = detail.policy
loadedOnce.value = true
if (!version.value)
await loadVersion()
if (generation !== detailGeneration)
@@ -453,7 +451,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
setPhase({ kind: 'failed' })
}
async function restartNow() {
async function restartNow(confirmation?: SystemRestartPlan) {
if (restarting.value)
return
@@ -472,7 +470,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
try {
// 进程可能在返回响应前退出,由下面的目标版本探测判定是否完成。
await restartSystem({ silent: true })
await restartSystem(confirmation, { silent: true })
}
catch (error: unknown) {
if (error instanceof ApiError && error.status > 0) {
@@ -501,7 +499,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
updateError,
lastFailedOperation,
needRestart,
loadedOnce,
updateLogs,
updateStreaming,
updateStreamError,
@@ -18,6 +18,7 @@ const props = withDefaults(
const planPalettes: Record<string, string> = {
free: 'bg-cp-cyan-container text-cp-cyan-on-container',
plus: 'bg-cp-blue-container-strong text-cp-blue-on-container',
pro: 'bg-cp-purple-container-strong text-cp-purple-on-container',
prolite: 'bg-cp-purple-container text-cp-purple-on-container',
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { ArrowRight, Info } from '@lucide/vue'
import { ArrowRight } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{ forecast: AccountQuotaForecast }>()
@@ -26,8 +26,7 @@ const metrics = computed(() => [
<div>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-cp-xs text-cp-text-secondary">本周期已用</span>
<span class="inline-flex items-center gap-1 rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
<Info class="size-3" aria-hidden="true" />
<span class="inline-flex items-center rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
{{ forecast.lowSample ? '初步估算 · 仅供参考' : '估算值 · 仅供参考' }}
</span>
</div>
@@ -70,14 +69,5 @@ const metrics = computed(() => [
</div>
</div>
</div>
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2 text-cp-xs">
<span class="text-cp-text-secondary">更新时的剩余额度</span>
<span class="font-mono font-emphasis text-cp-text [html[data-theme=light]_&]:font-medium">
{{ forecast.remainingTokensDisplay }} Tokens
<span class="mx-1 text-cp-text-tertiary">/</span>
{{ forecast.remainingUsdDisplay }}
</span>
</div>
</section>
</template>
@@ -6,7 +6,7 @@ import { BaseSkeleton } from '@codex-proxy/ui'
<div role="status" aria-busy="true">
<span class="sr-only">正在加载额度预测</span>
<div aria-hidden="true">
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex items-center justify-between gap-3">
<BaseSkeleton shape="text" class="w-16" />
@@ -33,11 +33,6 @@ import { BaseSkeleton } from '@codex-proxy/ui'
</div>
</div>
</div>
<div class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-28" />
<BaseSkeleton shape="text" class="w-32" />
</div>
</div>
</div>
</div>
@@ -27,7 +27,7 @@ const { now, pause, resume } = useNow({
scheduler: callback => useIntervalFn(callback, 30_000),
})
const options = computed(() => report.value?.forecasts.map(item => ({
label: item.extrapolated ? `${item.targetDays}天折算` : item.source?.label ?? (item.period === 'weekly' ? '周额度' : '月额度'),
label: item.period === 'weekly' ? '周额度' : '月额度',
value: item.period,
})) ?? [
{ label: '周额度', value: 'weekly' },
@@ -75,9 +75,9 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<AccountIdentityCell :account="account" show-plan title-mode="email" class="max-w-full">
<AccountIdentityCell :account="account" show-plan title-mode="email" meta-position="secondary" meta-size="xs" class="max-w-full">
<template #meta>
<ProviderIconGroup :provider="account.provider" size="sm" />
<ProviderIconGroup :provider="account.provider" size="xs" />
</template>
</AccountIdentityCell>
<BaseSegmented v-model="period" label="预测周期" :options="options" class="w-48" />
@@ -104,7 +104,10 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</div>
<template #footer>
<div class="mr-auto flex min-w-0 items-center gap-2">
<div class="mr-auto flex min-w-0 items-center gap-1">
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
<BasePopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger>
<BaseIconButton
@@ -132,16 +135,13 @@ function handleExplanationKeydown(event: KeyboardEvent) {
目前数据还较少,结果可能有较大波动
</p>
<p v-if="forecast?.extrapolated" class="m-0">
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算
本页预测由{{ forecast.source?.label ?? '当前周期' }}按 {{ forecast.targetDays }} 天折算
</p>
<p class="m-0">
剩余量以数据更新时间为准,等价费用不是实际账单或账户余额
预测以数据更新时间为准,等价费用不是实际账单或账户余额
</p>
</section>
</BasePopover>
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
</div>
<BaseButton variant="secondary" @click="open = false">
关闭
@@ -2,12 +2,13 @@
import type { AccountRow } from '../../constants'
import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui'
import { RefreshCw, UserRound } from '@lucide/vue'
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { formatProviderLabel } from '@/utils/providers'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountProfileModal from '../AccountProfileModal/index.vue'
import AccountQuotaForecastModal from '../AccountQuotaForecastModal/index.vue'
import AccountQuotaPanelEntry from './Entry.vue'
import AccountResetCredits from './ResetCredits.vue'
@@ -17,6 +18,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
accountUpdated: [account: AccountRow]
refreshQuota: [accountId: string]
quotaReset: [accountId: string]
}>()
@@ -25,8 +27,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
const profileOpen = shallowRef(false)
const forecastOpen = shallowRef(false)
const hasForecast = computed(() => props.account.authenticationKind !== 'api_key')
const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription)
const hasActions = computed(() => hasPersonalInfo.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits)
const hasActions = computed(() => hasPersonalInfo.value || hasForecast.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits)
watch(hasPersonalInfo, (available) => {
if (!available)
profileOpen.value = false
@@ -69,6 +73,17 @@ watch(hasPersonalInfo, (available) => {
:account="account"
@consumed="emit('quotaReset', $event)"
/>
<BaseIconButton
v-if="hasForecast"
label="预测周/月额度"
size="sm"
variant="ghost"
aria-haspopup="dialog"
:pressed="forecastOpen"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
</BaseIconButton>
<BaseIconButton
v-if="account.capabilities.quotaRefresh"
variant="ghost"
@@ -107,4 +122,10 @@ watch(hasPersonalInfo, (available) => {
v-model="profileOpen"
:account="account"
/>
<AccountQuotaForecastModal
v-if="hasForecast"
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
</template>
@@ -1,21 +1,15 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { BaseIconButton, BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { ChartNoAxesCombined, Sigma } from '@lucide/vue'
import { computed, ref } from 'vue'
import { BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { modelSuccessRateTextClass } from '../constants'
import AccountQuotaForecastModal from './AccountQuotaForecastModal/index.vue'
const props = defineProps<{
account: AccountRow
}>()
const emit = defineEmits<{
accountUpdated: [account: AccountRow]
}>()
const forecastOpen = ref(false)
type AccountModelUsage = AccountRow['usage']['models'][number]
const totalBilling = computed(() => props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'))
@@ -80,21 +74,9 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<div class="min-w-0 pt-4 xl:flex xl:min-h-0 xl:flex-col xl:pt-0 xl:pl-4">
<div class="mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2">
<div class="flex shrink-0 items-center gap-1">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<BaseIconButton
v-if="account.authenticationKind !== 'api_key'"
label="预测周/月额度"
size="sm"
class="h-5 w-5"
aria-haspopup="dialog"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
</BaseIconButton>
</div>
<h3 class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<div class="ml-auto flex items-baseline gap-4">
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
@@ -134,11 +116,4 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
</div>
</section>
<AccountQuotaForecastModal
v-if="account.authenticationKind !== 'api_key'"
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
</template>
+1 -1
View File
@@ -37,7 +37,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
format: value => accountProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
{ key: 'planType', label: '套餐', kind: 'status', sortable: true },
{ key: 'planType', label: '订阅', kind: 'status', sortable: true },
{ key: 'usage', label: '用量', kind: 'custom', size: '2xl', sortable: true },
{ key: 'groups', label: '账号分组', kind: 'status' },
{
+2 -4
View File
@@ -344,13 +344,11 @@ const {
<AccountQuotaPanel
:account="row"
:refreshing="refreshingQuotaAccountIds.has(row.id)"
@account-updated="void replaceAccount($event)"
@quota-reset="handleQuotaReset"
@refresh-quota="handleRefreshQuota"
/>
<AccountUsagePanel
:account="row"
@account-updated="void replaceAccount($event)"
/>
<AccountUsagePanel :account="row" />
</div>
</template>
</BaseTable>
@@ -46,7 +46,7 @@ async function resetBudget() {
<BaseModal
v-model="open"
title="重置已用额度"
description="清零所选周期的已用金额,原重置时间保持不变"
description="清零所选周期的已用金额,下次使用时重新确定重置日期"
size="sm"
:dismissible="!loading"
>
@@ -15,7 +15,7 @@ const emit = defineEmits<{
<template>
<div class="flex items-center gap-2">
<code class="block min-w-0 truncate font-mono text-cp-sm font-emphasis text-cp-text">
<code class="block min-w-0 overflow-hidden whitespace-nowrap font-mono text-cp-sm font-emphasis text-cp-text">
{{ prefix }}••••••••••••••••
</code>
<BaseIconButton
@@ -5,6 +5,7 @@ import { CircleAlert, LayoutGrid, List, Puzzle, Search, Settings2 } from '@lucid
import { computed, shallowRef, watch } from 'vue'
import { currentPluginInstance, PLUGIN_STATUS_LABELS, pluginStatus, pluginStatusType } from '../utils/catalog'
import PluginCapabilityTags from './PluginCapabilityTags.vue'
import PluginCompatibilityWarning from './PluginCompatibilityWarning.vue'
import PluginIcon from './PluginIcon.vue'
const props = defineProps<{ plugins: InstalledPlugin[], loading: boolean }>()
@@ -82,7 +83,8 @@ const columns = defineTableColumns<(typeof rows.value)[number]>([
</div>
<span class="truncate text-cp-xs text-cp-text-secondary">{{ plugin.artifact.metadata.publisher }}</span>
</div>
<BaseTag :type="pluginStatusType(pluginStatus(plugin))" size="sm">
<PluginCompatibilityWarning v-if="currentPluginInstance(plugin)?.compatibilityWarning" :instance="currentPluginInstance(plugin)!" />
<BaseTag v-else :type="pluginStatusType(pluginStatus(plugin))" size="sm">
<CircleAlert v-if="pluginStatus(plugin) === 'failed'" class="mr-1 size-3.5" />
{{ PLUGIN_STATUS_LABELS[pluginStatus(plugin)] }}
</BaseTag>
@@ -117,7 +119,8 @@ const columns = defineTableColumns<(typeof rows.value)[number]>([
</div>
</template>
<template #status="{ row }">
<BaseTag :type="pluginStatusType(pluginStatus(row))">
<PluginCompatibilityWarning v-if="currentPluginInstance(row)?.compatibilityWarning" :instance="currentPluginInstance(row)!" />
<BaseTag v-else :type="pluginStatusType(pluginStatus(row))">
<CircleAlert v-if="pluginStatus(row) === 'failed'" class="mr-1 size-3.5" />
{{ PLUGIN_STATUS_LABELS[pluginStatus(row)] }}
</BaseTag>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { PluginInstance } from '@/api'
import PluginHelpPopover from './PluginHelpPopover.vue'
defineProps<{ instance: PluginInstance }>()
</script>
<template>
<PluginHelpPopover v-if="instance.compatibilityWarning" :label="`${instance.name}不兼容`" warning>
<p class="m-0 font-emphasis text-cp-text">
{{ instance.enabled ? '不兼容,无法启动' : '已停用,无法启动' }}
</p>
<p class="m-0">
{{ instance.compatibilityWarning }}
</p>
<p class="m-0">
配置与数据保留,安装兼容版本后可重新启用
</p>
</PluginHelpPopover>
</template>
@@ -9,6 +9,7 @@ import { RouterLink } from 'vue-router'
import { configurationStatus, currentPluginInstance, hasPluginSettings, PLUGIN_STATUS_LABELS, pluginStatusType } from '../utils/catalog'
import { artifactForInstance } from '../utils/model'
import { pluginPageLocation } from '../utils/navigation'
import PluginCompatibilityWarning from './PluginCompatibilityWarning.vue'
import PluginConfigurationSummary from './PluginConfigurationSummary.vue'
import PluginHelpPopover from './PluginHelpPopover.vue'
import PluginLegacyConfigurations from './PluginLegacyConfigurations.vue'
@@ -88,7 +89,8 @@ watch(() => props.initialSection, value => section.value = value)
<div class="flex flex-wrap items-center gap-2">
<strong class="min-w-0 flex-1 wrap-break-word text-cp-sm">当前版本</strong>
<BaseTag>{{ artifactForInstance(instance, plugin.artifacts)?.metadata.version ?? '版本不可用' }}</BaseTag>
<BaseTag :type="pluginStatusType(configurationStatus(instance))">
<PluginCompatibilityWarning v-if="instance.compatibilityWarning" :instance="instance" />
<BaseTag v-else :type="pluginStatusType(configurationStatus(instance))">
<CircleAlert v-if="configurationStatus(instance) === 'failed'" class="mr-1 size-3.5" />
{{ PLUGIN_STATUS_LABELS[configurationStatus(instance)] }}
</BaseTag>
@@ -113,13 +115,13 @@ watch(() => props.initialSection, value => section.value = value)
<BaseIconButton v-if="instance.configurationRequired || (currentArtifact && hasPluginSettings(currentArtifact))" size="sm" variant="secondary" label="设置" :disabled="busy" @click="$emit('edit', instance)">
<Settings2 class="size-4" />
</BaseIconButton>
<BaseIconButton v-if="configurationStatus(instance) === 'failed'" label="重新启动" size="sm" variant="secondary" :disabled="busy" @click="$emit('enable', instance)">
<BaseIconButton v-if="configurationStatus(instance) === 'failed'" label="重新启动" size="sm" variant="secondary" :disabled="busy || Boolean(instance.compatibilityWarning)" @click="$emit('enable', instance)">
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseIconButton v-if="instance.enabled" label="停用" size="sm" variant="secondary" :disabled="busy" @click="$emit('disable', instance)">
<Power class="size-4" />
</BaseIconButton>
<BaseIconButton v-else :label="instance.configurationRequired ? '完成设置并启用' : '启用'" size="sm" variant="secondary" :disabled="busy" @click="$emit('enable', instance)">
<BaseIconButton v-else :label="instance.configurationRequired ? '完成设置并启用' : '启用'" size="sm" variant="secondary" :disabled="busy || Boolean(instance.compatibilityWarning)" @click="$emit('enable', instance)">
<Play class="size-4" />
</BaseIconButton>
<BaseIconButton v-if="plugin.artifacts.length > 1" label="回退版本" size="sm" variant="secondary" :disabled="busy" @click="$emit('rollback', instance)">
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { BaseIconButton, BasePopover } from '@codex-proxy/ui'
import { Info } from '@lucide/vue'
import { Info, TriangleAlert } from '@lucide/vue'
import { shallowRef, useId } from 'vue'
defineProps<{ label: string }>()
defineProps<{ label: string, warning?: boolean }>()
const open = shallowRef(false)
const descriptionId = useId()
@@ -30,7 +30,8 @@ function handleKeydown(event: KeyboardEvent) {
:aria-describedby="open ? descriptionId : undefined"
@keydown="handleKeydown"
>
<Info class="size-3.5" />
<TriangleAlert v-if="warning" class="size-4 text-cp-warning" />
<Info v-else class="size-3.5" />
</BaseIconButton>
</template>
<div :id="descriptionId" role="tooltip" class="grid max-w-80 gap-2 p-3 text-cp-xs leading-relaxed break-words text-cp-text-secondary">
@@ -4,6 +4,7 @@ import { BaseIconButton, BaseTag } from '@codex-proxy/ui'
import { Play, Power, Settings2, Trash2 } from '@lucide/vue'
import { configurationStatus, PLUGIN_STATUS_LABELS, pluginStatusType } from '../utils/catalog'
import { artifactForInstance } from '../utils/model'
import PluginCompatibilityWarning from './PluginCompatibilityWarning.vue'
defineProps<{ instances: PluginInstance[], artifacts: PluginArtifact[], busy: boolean }>()
defineEmits<{
@@ -30,7 +31,8 @@ defineEmits<{
<BaseTag size="sm">
{{ artifactForInstance(instance, artifacts)?.metadata.version }}
</BaseTag>
<BaseTag size="sm" :type="pluginStatusType(configurationStatus(instance))">
<PluginCompatibilityWarning v-if="instance.compatibilityWarning" :instance="instance" />
<BaseTag v-else size="sm" :type="pluginStatusType(configurationStatus(instance))">
{{ PLUGIN_STATUS_LABELS[configurationStatus(instance)] }}
</BaseTag>
<div class="flex shrink-0 items-center gap-1">
@@ -40,7 +42,7 @@ defineEmits<{
<BaseIconButton v-if="instance.enabled" label="停用历史配置" variant="secondary" size="sm" :disabled="busy" @click="$emit('disable', instance)">
<Power class="size-4" />
</BaseIconButton>
<BaseIconButton v-else label="启动" size="sm" variant="secondary" :disabled="busy" @click="$emit('enable', instance)">
<BaseIconButton v-else label="启动" size="sm" variant="secondary" :disabled="busy || Boolean(instance.compatibilityWarning)" @click="$emit('enable', instance)">
<Play class="size-4" />
</BaseIconButton>
<BaseIconButton v-if="!instance.enabled" label="删除历史配置" variant="secondary" size="sm" class="group" :disabled="busy" @click="$emit('delete', instance)">
@@ -5,6 +5,7 @@ import { ArrowDownToLine, ChevronDown, CircleAlert, Play, Trash2 } from '@lucide
import { computed, shallowRef, useId } from 'vue'
import { configurationStatus, PLUGIN_STATUS_LABELS, pluginStatusType } from '../utils/catalog'
import { shortDigest, sourceDetail, sourceLabel } from '../utils/model'
import PluginCompatibilityWarning from './PluginCompatibilityWarning.vue'
const props = defineProps<{
artifact: PluginArtifact
@@ -28,7 +29,8 @@ const detailsId = useId()
<div v-if="isCurrent && current" class="flex flex-wrap items-center gap-2">
<strong class="min-w-0 flex-1 wrap-break-word text-cp-sm">当前版本</strong>
<BaseTag>{{ artifact.metadata.version }}</BaseTag>
<BaseTag :type="pluginStatusType(configurationStatus(current))">
<PluginCompatibilityWarning v-if="current.compatibilityWarning" :instance="current" />
<BaseTag v-else :type="pluginStatusType(configurationStatus(current))">
<CircleAlert v-if="configurationStatus(current) === 'failed'" class="mr-1 size-3.5" />
{{ PLUGIN_STATUS_LABELS[configurationStatus(current)] }}
</BaseTag>
@@ -84,6 +84,10 @@ export function usePluginInstances({ artifacts, refresh, notifyError, runAction,
}
async function requestInstanceEnable(instance: PluginInstance) {
if (instance.compatibilityWarning) {
toast.error(instance.compatibilityWarning)
return
}
if ((instance.enabled && configurationStatus(instance) !== 'failed') || savingInstance.value || pendingEnable.value)
return
if (instance.configurationRequired) {
+5 -2
View File
@@ -8,7 +8,7 @@ export interface InstalledPlugin {
source?: PluginUpdateSourceBinding
}
export type PluginCatalogStatus = 'unaccepted' | 'unconfigured' | 'enabled' | 'disabled' | 'pending' | 'failed'
export type PluginCatalogStatus = 'unaccepted' | 'unconfigured' | 'enabled' | 'disabled' | 'pending' | 'failed' | 'incompatible'
export function groupInstalledPlugins(artifacts: PluginArtifact[], instances: PluginInstance[], sources: PluginUpdateSourceBinding[]): InstalledPlugin[] {
const groups = new Map<string, InstalledPlugin>()
@@ -34,6 +34,8 @@ export function groupInstalledPlugins(artifacts: PluginArtifact[], instances: Pl
}
export function configurationStatus(instance: PluginInstance): PluginCatalogStatus {
if (instance.compatibilityWarning)
return 'incompatible'
if (instance.configurationRequired)
return 'unconfigured'
if (!instance.enabled)
@@ -59,6 +61,7 @@ export const PLUGIN_STATUS_LABELS: Record<PluginCatalogStatus, string> = {
disabled: '已停用',
pending: '等待生效',
failed: '需要处理',
incompatible: '不兼容',
}
export function pluginStatusType(status: PluginCatalogStatus) {
@@ -66,7 +69,7 @@ export function pluginStatusType(status: PluginCatalogStatus) {
return 'success' as const
if (status === 'failed')
return 'danger' as const
if (status === 'pending' || status === 'unconfigured' || status === 'unaccepted')
if (status === 'pending' || status === 'unconfigured' || status === 'unaccepted' || status === 'incompatible')
return 'warning' as const
return 'neutral' as const
}
@@ -30,7 +30,7 @@ const { accounts, loading, error, pagination, search, setPage, setPageSize, remo
const columns = defineTableColumns<OutboundProxyAccount>([
{ key: 'identity', label: '账号', kind: 'identity', size: '3xl' },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'md', align: 'center' },
{ key: 'plan', label: '套餐', kind: 'custom', size: 'sm', align: 'center' },
{ key: 'plan', label: '订阅', kind: 'custom', size: 'sm', align: 'center' },
{ key: 'groups', label: '分组', kind: 'custom', size: 'lg', align: 'center' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'xs', align: 'center' },
])
@@ -118,6 +118,8 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
accountEmail: options.accountEmail,
accountName: options.accountEmail.split('@')[0] ?? null,
accountNotes: null,
accountPlanType: null,
accountPlanTypeDisplay: null,
route: stream ? '/v1/responses' : '/v1/chat/completions',
model: options.model,
requestedModel: options.model,
@@ -6,6 +6,7 @@ import { BaseIconButton, BaseInput, BaseTable, BaseTableColumnSettings, BaseTabl
import { Eye, RefreshCw, Search } from '@lucide/vue'
import { shallowRef, toRef } from 'vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { opsErrorSummary } from '../utils/opsErrorPresentation'
@@ -152,6 +153,15 @@ function upstreamSendStateText(value: string | null | undefined) {
:authentication-kind="row.authenticationKind"
/>
</template>
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.accountPlanType"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
size="sm"
/>
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #message="{ row }">
<div class="min-w-0 py-0.5" :title="row.message || opsErrorSummary(row)">
<div class="flex min-w-0 items-center gap-2">

Some files were not shown because too many files have changed in this diff Show More