Compare commits

...
6 Commits
Author SHA1 Message Date
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
88 changed files with 1860 additions and 389 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>,
}
@@ -1142,6 +1155,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,
@@ -772,10 +780,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,
@@ -1283,6 +1285,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,
@@ -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())
}
}
@@ -550,7 +550,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 +560,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 +600,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 +775,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 +869,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()),
+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")
}
}
@@ -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 {
@@ -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("插件不支持当前网关版本或平台"))?;
@@ -849,7 +849,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())
}
}
@@ -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)?,
})
@@ -791,6 +809,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")?,
@@ -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>,
}
@@ -793,6 +803,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>;
}
@@ -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)
}
@@ -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;
}
@@ -765,7 +816,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 +1410,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 +1828,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` 身份,独立教学示例也不例外
+19 -8
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` 表示没有可选的源窗口。
@@ -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 或把敏感字段混入普通配置
+4 -2
View File
@@ -794,8 +794,10 @@ worker 复用连接测试探针执行真实上游调用,并按冻结代次处
### 在线更新与回滚
在线更新校验 Release host、大小、SHA-256 和归档路径,并只允许同一大版本内更新;更新与回滚的目标发行清单
还要通过全部启用插件的静态兼容检查,文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
在线更新校验 Release host、大小、SHA-256、归档路径和发行身份,并只允许同一大版本内更新。
下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。
Host 在重启操作锁内重新检查,Admin 仅在确认匹配时原子停用对应插件并发布配置,保留密钥和私有数据;取消不产生写入。
回滚仍要求全部启用插件与旧宿主兼容;更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
状态文件记录操作结果,SSE 终态在状态落盘后发送,Host 关闭与任务析构都必须收敛状态
+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,
})
}
+4
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
@@ -436,6 +438,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">
关闭
@@ -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
}
@@ -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,
@@ -5,6 +5,7 @@ import { BaseCard, BaseEmpty, BaseSegmented, BaseTable, defineTableColumns } fro
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { formatDuration, formatPercent, formatUsd } from '../utils/format'
@@ -31,38 +32,46 @@ const dimensionOptions = [
{ label: '错误', value: 'failureClass' },
]
const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
const resultDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
)
const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayItem>([
{
key: 'nameDisplay',
label: '维度',
kind: 'custom',
size: 'xl',
},
...(resultDimension.value === 'account'
? defineTableColumns<DiagnosticDisplayItem>([
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
])
: []),
{
key: 'requestCount',
label: '请求',
kind: 'numeric',
size: 'sm',
size: 'xs',
fixedWidth: true,
},
{
key: 'errorCount',
label: '失败',
kind: 'numeric',
size: 'sm',
size: 'xs',
fixedWidth: true,
},
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg' },
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg', fixedWidth: true },
{
key: 'estimatedCost',
label: '费用',
kind: 'numeric',
size: 'sm',
fixedWidth: true,
},
])
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
const resultDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
)
]))
type DiagnosticDisplayItem = Diagnostics['items'][number] & {
nameDisplay: ReturnType<typeof diagnosticNameDisplay>
@@ -98,7 +107,7 @@ function diagnosticNameDisplay(name: string) {
as="article"
title="热点诊断"
:description="`按${resultDimensionLabel}聚合`"
class="h-105 min-h-105 max-h-105 min-w-0 w-full lg:h-full lg:min-h-90 lg:max-h-105"
class="max-h-105 min-w-0 w-full xl:h-full"
>
<template #actions>
<BaseSegmented
@@ -114,7 +123,7 @@ function diagnosticNameDisplay(name: string) {
<BaseTable
v-if="hasData"
:key="resultDimension"
class="min-h-0 w-full xl:contain-[size]"
class="min-h-0 max-h-80 w-full xl:max-h-none xl:contain-[size]"
:columns="diagnosticColumns"
:rows="displayItems"
density="compact"
@@ -148,6 +157,16 @@ function diagnosticNameDisplay(name: string) {
</div>
</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 #requestCount="{ row }">
<span
class="grid justify-items-end gap-1 font-mono leading-none tabular-nums"
@@ -205,7 +224,7 @@ function diagnosticNameDisplay(name: string) {
surface="none"
:title="loading ? '正在加载热点诊断数据' : '暂无诊断数据'"
description="当前范围没有可诊断的请求记录"
class="h-full place-content-center"
class="h-full min-h-40 place-content-center"
/>
</template>
</BaseCard>
@@ -4,6 +4,7 @@ import type { UsageDisplayRecord } from '../utils/records'
import { BaseTable } from '@codex-proxy/ui'
import { Minimize2 } from '@lucide/vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import {
usageAccountText,
usageAuthenticationKind,
@@ -72,6 +73,16 @@ withDefaults(
</span>
</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 #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
+4 -2
View File
@@ -14,14 +14,16 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
kind: 'identity',
size: '3xl',
},
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
{
key: 'provider',
label: '平台/类型',
kind: 'status',
size: 'sm',
size: 'md',
fixedWidth: true,
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom', size: 'lg' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
+14 -17
View File
@@ -1,22 +1,19 @@
# v3.18.0
## 新增功能
- 插件中间件覆盖 HTTP、WebSocket 消息、公开服务、模型请求与单次上游尝试,支持按阶段配置启停、顺序和失败处理
- 新增 OpenAI / xAI 受管上游适配器,插件可指定业务上游并解析结果,同时复用宿主的账号选择、重试和用量结算
- 统一插件事件观察接口,分别订阅请求完成与用量、上游 WebSocket 事件;账号只读事实增加名称和邮箱
# v3.18.2
## 问题修复
- HTTP Responses 请求省略 `stream` 时默认返回 JSON,修复调用方按普通 JSON 解析时收到 SSE 的问题;显式流式请求和 WebSocket 默认行为保持不变
- OpenAI 上游连接池、TCP keepalive 和 HTTP/2 keepalive 设置与官方 Codex 默认行为对齐
- 二进制部署自重启保留启动时解析的安装路径,修复更新替换可执行文件后重启路径失效的问题,并补充自重启配置示例
- 调整账号列表的并发容量对齐和每日用量显示,改善额度摘要的布局
- 修复热点诊断在分组超过 100 项时请求占比偏高的问题,分母使用筛选范围内全部匹配请求;重试率按发生重试的请求数计算,同一请求多次重试只计一次
- 系统更新弹窗点击后立即打开,加载期间显示骨架屏;修复关闭时发布说明提前消失、面板高度跳变的问题
## 插件与升级说明
## 计费与管理界面
- 插件安装采用完整信任模型,不再以权限清单表达隔离;插件与网关使用相同系统身份,可访问数据、凭据、配置与网络,只安装可信来源
- 当前插件合同为 `manifestVersion: 2`、进程协议 `2`、中间件能力 `3`。旧合同插件需使用匹配的 SDK 与 CLI 重新构建打包,不能仅修改版本字段;Workbench 可安装 [v0.2.0](https://github.com/zyycn/codex-proxy-plugins/releases/tag/v0.2.0)
- v3.17.0 内置更新器不支持本版的插件兼容声明,需使用完整平台发行包或更新容器镜像。二进制部署请成套替换程序、Web 资源和 `plugins/official` 目录;升级前停用不兼容插件,升级后安装兼容版本并检查配置
- 新增数据库迁移 `0020_plugin_trust.sql`,清理插件制品元数据中的旧权限字段,保留包体、摘要、接受记录与实例配置;已发布迁移保持不变。升级前请备份数据库并保留现有配置
- 依赖 HTTP Responses 默认 SSE 的调用方需显式传入 `stream: true`
- 补充 GPT-6.1 Sol 内置计费规则,覆盖长上下文、服务档位、缓存与搜索费用
- 热点诊断的账号维度和使用记录增加当前套餐列,调整列宽、对齐及套餐徽标对比度,减少诊断卡片空白
- 精简额度预测弹窗,移除剩余额度行,周期标签统一为“周额度”和“月额度”;平台与套餐放到邮箱下方,说明图标移到更新时间后
- API 密钥前缀超出列宽时直接裁切,保留脱敏显示与复制按钮
## 升级说明
- 额度预测接口不再返回 `remainingTokens`、`remainingUsd` 及对应的 `*Display` 字段;直接使用该接口的客户端需同步调整,预计总量的计算规则保持不变
- 套餐列展示账号当前套餐,不是请求发生时的套餐快照;月额度缺少对应上游窗口时仍按 30 天折算,并保留折算提示
- v3.18.0 的更新清单解析限制仍适用,需使用完整平台发行包或容器镜像升级;二进制部署须成套替换程序、Web 资源和 `plugins/official` 目录
+1 -1
View File
@@ -1 +1 @@
version: "3.18.0"
version: "3.18.2"