refactor: clarify ownership and simplify plugin management

Centralize audit classifications and retention policy, move cleanup orchestration to Host, and require explicit plugin lifecycle implementations.

Remove unused interfaces and redundant state, type-check the standalone plugin bridge, and split management flows by responsibility.

Update architecture boundaries and add focused regression coverage.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-29 20:56:10 +08:00
co-authored by Codex
parent ec21d83415
commit 3a04e166c2
85 changed files with 2392 additions and 1729 deletions
+1
View File
@@ -378,6 +378,7 @@ async fn launch(
);
let mut plan = store.take_worker_contributions();
plan.push(gateway_host::retention::worker(store.retention())?);
plan.extend(core.take_worker_contributions());
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
@@ -210,6 +210,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"plugin_distribution",
"process",
"proxy_probe",
"retention",
"serve",
"system_update",
"workers",
+20 -11
View File
@@ -16,8 +16,8 @@ use tracing::{info, warn};
use crate::{
model::backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig,
BackupTriggerKind, build_backup_seed, code,
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition,
BackupStorageConfig, BackupTriggerKind, build_backup_seed, code,
},
ports::backup::{
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest,
@@ -224,8 +224,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
BackupStatus::Uploading,
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
update,
now,
)
@@ -274,8 +273,7 @@ impl BackupTask {
self.repository
.transition_status(
&record.id,
BackupStatus::Uploading,
BackupStatus::Completed,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
StatusTransitionUpdate::default(),
now,
)
@@ -402,8 +400,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
BackupStatus::Uploading,
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
update,
now,
)
@@ -471,8 +468,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Uploading,
BackupStatus::Completed,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
StatusTransitionUpdate::default(),
Utc::now(),
)
@@ -555,7 +551,12 @@ impl BackupTask {
};
let _ = self
.repository
.transition_status(&record.id, record.status, BackupStatus::Failed, update, now)
.transition_status(
&record.id,
status_transition(record.status, BackupStatus::Failed)?,
update,
now,
)
.await
.map_err(repo_error)?;
let _ = self.dump.cleanup_staging(&record.id).await;
@@ -639,6 +640,14 @@ fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
}
}
fn status_transition(
from: BackupStatus,
to: BackupStatus,
) -> Result<BackupStatusTransition, WorkerTaskError> {
BackupStatusTransition::try_new(from, to)
.ok_or_else(|| WorkerTaskError::safe("backup status transition is invalid"))
}
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
WorkerTaskError::safe(format!("backup store failed: {error}"))
}
-5
View File
@@ -272,11 +272,6 @@ impl AdminServices {
self.observability.as_ref()
}
#[must_use]
pub fn settings_handle(&self) -> Arc<dyn SettingsService> {
self.settings.clone()
}
pub fn settings(&self) -> &dyn SettingsService {
self.settings.as_ref()
}
@@ -0,0 +1,153 @@
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。
use super::{
MutationActor, MutationContext,
auth::{AdminAuditEvent, AuditActorKind, admin_session_actor_ref},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationAuditOperation {
ModelPricingSync,
ModelPricingUpdate,
RuntimeSettingsReplace,
AdminApiKeyChanged { exists: bool },
AccountGroupCreate,
AccountGroupUpdate,
AccountGroupEnabled { enabled: bool },
AccountGroupDelete,
ClientApiKeyResetBudget,
ProviderAccountUpdate,
BackupStorageUpdate,
BackupScheduleUpdate,
ProviderAccountAdaptConcurrency,
ProviderAccountRecover,
ProviderAccountBatchUpdate,
ProviderAccountDelete,
ProviderAccountExportCredentials,
PluginArtifactInstall,
PluginArtifactAccept,
PluginArtifactDelete,
PluginInstanceConfigure,
PluginInstanceDelete,
PluginSourceCredentialCreate,
PluginSourceCredentialDelete,
ClientApiKeyUpdateBudgetLimits,
ClientApiKeyCreate,
ClientApiKeyUpdate,
ClientApiKeyEnabled { enabled: bool },
ClientApiKeyDelete,
PluginSourceChangeSource,
PluginSourceDelete,
OutboundProxyCreate,
OutboundProxyUpdate,
OutboundProxyDelete,
OutboundProxyTest,
AccountGroupPluginReconcile,
ClientApiKeyPluginReconcile,
ProviderAccountImportDocument,
ProviderAccountRotateCredential,
ProviderAccountRefreshCredential,
ProviderAccountAuthorize,
ProviderAccountReauthorize,
}
impl MutationAuditOperation {
#[must_use]
pub const fn classification(self) -> (&'static str, &'static str) {
match self {
Self::ModelPricingSync => ("pricing.sync", "model_pricing"),
Self::ModelPricingUpdate => ("pricing.update", "model_pricing"),
Self::RuntimeSettingsReplace => ("settings.replace", "runtime_settings"),
Self::AdminApiKeyChanged { exists } => (
if exists {
"admin_api_key.replace"
} else {
"admin_api_key.delete"
},
"runtime_settings",
),
Self::AccountGroupCreate => ("create", "account_group"),
Self::AccountGroupUpdate => ("update", "account_group"),
Self::AccountGroupEnabled { enabled } => {
(if enabled { "enable" } else { "disable" }, "account_group")
}
Self::AccountGroupDelete => ("delete", "account_group"),
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
Self::ProviderAccountUpdate => ("update", "provider_account"),
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
Self::ProviderAccountRecover => ("recover", "provider_account"),
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
Self::ProviderAccountDelete => ("delete", "provider_account"),
Self::ProviderAccountExportCredentials => ("export_credentials", "provider_account"),
Self::PluginArtifactInstall => ("install", "plugin_artifact"),
Self::PluginArtifactAccept => ("accept", "plugin_artifact"),
Self::PluginArtifactDelete => ("delete", "plugin_artifact"),
Self::PluginInstanceConfigure => ("configure", "plugin_instance"),
Self::PluginInstanceDelete => ("delete", "plugin_instance"),
Self::PluginSourceCredentialCreate => ("create", "plugin_source_credential"),
Self::PluginSourceCredentialDelete => ("delete", "plugin_source_credential"),
Self::ClientApiKeyUpdateBudgetLimits => ("update_budget_limits", "client_api_key"),
Self::ClientApiKeyCreate => ("create", "client_api_key"),
Self::ClientApiKeyUpdate => ("update", "client_api_key"),
Self::ClientApiKeyEnabled { enabled } => {
(if enabled { "enable" } else { "disable" }, "client_api_key")
}
Self::ClientApiKeyDelete => ("delete", "client_api_key"),
Self::PluginSourceChangeSource => ("change_source", "plugin_source"),
Self::PluginSourceDelete => ("delete", "plugin_source"),
Self::OutboundProxyCreate => ("create", "outbound_proxy"),
Self::OutboundProxyUpdate => ("update", "outbound_proxy"),
Self::OutboundProxyDelete => ("delete", "outbound_proxy"),
Self::OutboundProxyTest => ("test", "outbound_proxy"),
Self::AccountGroupPluginReconcile => ("plugin_reconcile", "account_group"),
Self::ClientApiKeyPluginReconcile => ("plugin_reconcile", "client_api_key"),
Self::ProviderAccountImportDocument => ("import_document", "provider_account"),
Self::ProviderAccountRotateCredential => ("rotate_credential", "provider_account"),
Self::ProviderAccountRefreshCredential => ("refresh_credential", "provider_account"),
Self::ProviderAccountAuthorize => ("authorize", "provider_account"),
Self::ProviderAccountReauthorize => ("reauthorize", "provider_account"),
}
}
}
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。
#[derive(Debug, Clone, Copy)]
pub struct MutationAuditIntent<'a> {
pub operation: MutationAuditOperation,
pub entity_ref: &'a str,
}
impl MutationAuditIntent<'_> {
#[must_use]
pub fn event(self, context: &MutationContext, changed_fields: Vec<String>) -> AdminAuditEvent {
let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor {
MutationActor::AdminSession { admin_user_id } => (
AuditActorKind::AdminSession,
Some(admin_user_id.clone()),
admin_session_actor_ref(admin_user_id),
),
MutationActor::AdminApiKey => (
AuditActorKind::AdminApiKey,
None,
"admin_api_key".to_owned(),
),
MutationActor::System => (AuditActorKind::System, None, "system".to_owned()),
};
let (action, entity_kind) = self.operation.classification();
AdminAuditEvent {
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
actor_kind,
actor_admin_user_id,
actor_ref,
request_id: Some(context.request_id.clone()),
action: action.to_owned(),
entity_kind: entity_kind.to_owned(),
entity_ref: self.entity_ref.to_owned(),
config_revision: None,
changed_fields,
occurred_at: chrono::Utc::now(),
}
}
}
@@ -124,6 +124,33 @@ impl fmt::Display for BackupStatus {
}
}
/// 已通过领域状态机校验的显式迁移。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackupStatusTransition {
from: BackupStatus,
to: BackupStatus,
}
impl BackupStatusTransition {
/// 校验并构造显式迁移;非法状态边返回 `None`。
#[must_use]
pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option<Self> {
from.allows_transition_to(to).then_some(Self { from, to })
}
/// 迁移要求的当前状态。
#[must_use]
pub const fn from(self) -> BackupStatus {
self.from
}
/// 迁移完成后的目标状态。
#[must_use]
pub const fn to(self) -> BackupStatus {
self.to
}
}
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
#[derive(Clone)]
pub struct UpdateBackupStorageCommand {
@@ -4,6 +4,7 @@ use std::num::{NonZeroU16, NonZeroU64};
pub mod account_groups;
pub mod accounts;
pub mod audit;
pub mod auth;
pub mod backup;
pub mod client_distribution;
@@ -19,6 +20,7 @@ pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
pub mod quota_forecast_sampling;
pub mod retention;
pub mod settings;
pub mod system;
@@ -0,0 +1,41 @@
//! 历史数据保留规则;存储实现只接受已校验的窗口。
use super::AdminError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetentionTarget {
ModelRequests,
OpsEvents,
AdminAuditEvents,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionPolicy {
usage_days: u32,
ops_days: u32,
audit_days: u32,
}
impl RetentionPolicy {
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
return Err(AdminError::invalid(
"请求保留期至少 31 天,事件与审计保留期必须为正数",
));
}
Ok(Self {
usage_days,
ops_days,
audit_days,
})
}
#[must_use]
pub const fn days(self, target: RetentionTarget) -> u32 {
match target {
RetentionTarget::ModelRequests => self.usage_days,
RetentionTarget::OpsEvents => self.ops_days,
RetentionTarget::AdminAuditEvents => self.audit_days,
}
}
}
@@ -17,8 +17,8 @@ use crate::model::{
MutationContext,
backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult,
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
BackupRecordSeed, BackupSettings, BackupStatusTransition, BackupStorageConfig,
ConnectionTestResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
},
};
use crate::ports::store::AdminStoreResult;
@@ -96,8 +96,7 @@ pub trait BackupRepository: Send + Sync {
async fn transition_status(
&self,
id: &str,
from: BackupStatus,
to: BackupStatus,
transition: BackupStatusTransition,
update: StatusTransitionUpdate,
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>>;
@@ -333,8 +332,7 @@ impl BackupRepository for UnavailableBackupRepository {
async fn transition_status(
&self,
_id: &str,
_from: BackupStatus,
_to: BackupStatus,
_transition: BackupStatusTransition,
_update: StatusTransitionUpdate,
_now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>> {
@@ -11,5 +11,6 @@ pub mod plugins;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod retention;
pub mod store;
pub mod system;
@@ -28,7 +28,7 @@ use gateway_core::runtime::extensions::ExtensionSetReference;
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。
#[async_trait]
pub trait PluginPreparation: Send + Sync {
pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
async fn configuration_ready(
&self,
@@ -44,36 +44,33 @@ pub trait PluginPreparation: Send + Sync {
&self,
snapshot: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError>;
/// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。
}
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None。
#[async_trait]
pub trait PluginRuntimeDiagnostics: Send + Sync {
async fn runtime_diagnostics(
&self,
_snapshot: &PluginInstanceSnapshot,
_published_revision: Option<u64>,
_published: Option<&ExtensionSetReference>,
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
None
}
snapshot: &PluginInstanceSnapshot,
published_revision: Option<u64>,
published: Option<&ExtensionSetReference>,
) -> Option<BTreeMap<String, PluginInstanceRuntime>>;
}
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期。
#[async_trait]
pub trait PluginStateLifecycle: Send + Sync {
async fn activate_state(
&self,
_prepared: &ExtensionSetReference,
_instance: &PluginInstance,
) -> Result<(), AdminError> {
Ok(())
}
async fn quiesce_instance(
&self,
_instance_id: &str,
_artifact_sha256: &str,
_revision: Revision,
) {
}
prepared: &ExtensionSetReference,
instance: &PluginInstance,
) -> Result<(), AdminError>;
async fn quiesce_instance(&self, instance_id: &str, artifact_sha256: &str, revision: Revision);
async fn migrate_state(
&self,
_prepared: &ExtensionSetReference,
_transition: PluginStateTransition,
) -> Result<(), AdminError> {
Err(AdminError::unavailable("插件状态迁移运行时不可用"))
}
prepared: &ExtensionSetReference,
transition: PluginStateTransition,
) -> Result<(), AdminError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -0,0 +1,23 @@
//! 有界保留期删除,不承载周期、暂停或重试策略。
use std::num::NonZeroU32;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use super::store::AdminStoreResult;
use crate::model::retention::{RetentionPolicy, RetentionTarget};
#[async_trait]
pub trait RetentionStore: Send + Sync {
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy>;
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除。
async fn purge_batch(
&self,
target: RetentionTarget,
now: DateTime<Utc>,
policy: RetentionPolicy,
limit: NonZeroU32,
) -> AdminStoreResult<u64>;
}
@@ -111,9 +111,12 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& crate::model::retention::RetentionPolicy::try_new(
command.usage_retention_days,
command.ops_event_retention_days,
command.audit_retention_days,
)
.is_ok()
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
@@ -14,9 +14,9 @@ use gateway_admin::{
auth::AdminAuditEvent,
backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery,
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig,
BackupTriggerKind, ConnectionTestResult, UpdateBackupScheduleCommand,
UpdateBackupStorageCommand,
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus,
BackupStatusTransition, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
},
},
ports::{
@@ -330,19 +330,18 @@ impl BackupRepository for FakeBackupRepository {
async fn transition_status(
&self,
id: &str,
from: BackupStatus,
to: BackupStatus,
transition: BackupStatusTransition,
update: StatusTransitionUpdate,
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>> {
let mut records = self.records.lock().expect("records");
let Some(record) = records
.iter_mut()
.find(|record| record.id == id && record.status == from)
.find(|record| record.id == id && record.status == transition.from())
else {
return Ok(None);
};
record.status = to;
record.status = transition.to();
if let Some(size) = update.size_bytes {
record.size_bytes = Some(size);
}
@@ -356,7 +355,11 @@ impl BackupRepository for FakeBackupRepository {
record.error_message = Some(message);
}
record.completed_at = update.completed_at.or_else(|| {
matches!(to, BackupStatus::Completed | BackupStatus::Failed).then_some(now)
matches!(
transition.to(),
BackupStatus::Completed | BackupStatus::Failed
)
.then_some(now)
});
record.updated_at = now;
Ok(Some(record.clone()))
@@ -0,0 +1,69 @@
use gateway_admin::model::{
MutationActor, MutationContext,
audit::{MutationAuditIntent, MutationAuditOperation},
auth::AuditActorKind,
};
#[test]
fn mutation_intent_preserves_actor_request_and_committed_field_names() {
let intent = MutationAuditIntent {
operation: MutationAuditOperation::PluginInstanceConfigure,
entity_ref: "instance-1",
};
for (actor, expected_kind, expected_user) in [
(
MutationActor::AdminSession {
admin_user_id: "admin-1".to_owned(),
},
AuditActorKind::AdminSession,
Some("admin-1"),
),
(
MutationActor::AdminApiKey,
AuditActorKind::AdminApiKey,
None,
),
(MutationActor::System, AuditActorKind::System, None),
] {
let event = intent.event(
&MutationContext {
actor,
request_id: "request-1".to_owned(),
},
vec!["configuration_json".to_owned()],
);
assert_eq!(event.actor_kind, expected_kind);
assert_eq!(event.actor_admin_user_id.as_deref(), expected_user);
assert_eq!(event.request_id.as_deref(), Some("request-1"));
assert_eq!(
(event.action.as_str(), event.entity_kind.as_str()),
("configure", "plugin_instance")
);
assert_eq!(event.entity_ref, "instance-1");
assert_eq!(event.changed_fields, ["configuration_json"]);
assert_eq!(event.config_revision, None);
}
}
#[test]
fn resulting_state_selects_the_existing_audit_classification() {
for enabled in [true, false] {
let action = if enabled { "enable" } else { "disable" };
assert_eq!(
MutationAuditOperation::AccountGroupEnabled { enabled }.classification(),
(action, "account_group"),
);
assert_eq!(
MutationAuditOperation::ClientApiKeyEnabled { enabled }.classification(),
(action, "client_api_key"),
);
}
assert_eq!(
MutationAuditOperation::AdminApiKeyChanged { exists: false }.classification(),
("admin_api_key.delete", "runtime_settings"),
);
assert_eq!(
MutationAuditOperation::AdminApiKeyChanged { exists: true }.classification(),
("admin_api_key.replace", "runtime_settings"),
);
}
@@ -4,19 +4,42 @@ use chrono::{TimeZone as _, Utc};
use secrecy::SecretString;
use gateway_admin::model::backup::{
BackupSettings, BackupStatus, BackupStorageConfig, BackupTriggerKind, build_object_key,
BackupSettings, BackupStatus, BackupStatusTransition, BackupStorageConfig, BackupTriggerKind,
build_object_key,
};
#[test]
fn status_machine_rejects_invalid_transitions() {
assert!(BackupStatus::Queued.allows_transition_to(BackupStatus::Dumping));
assert!(BackupStatus::Dumping.allows_transition_to(BackupStatus::Uploading));
assert!(BackupStatus::Uploading.allows_transition_to(BackupStatus::Completed));
assert!(BackupStatus::Completed.allows_transition_to(BackupStatus::Deleting));
assert!(!BackupStatus::Queued.allows_transition_to(BackupStatus::Completed));
assert!(!BackupStatus::Completed.allows_transition_to(BackupStatus::Dumping));
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Failed));
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Completed));
fn status_transition_accepts_every_legal_edge() {
let transitions = [
(BackupStatus::Queued, BackupStatus::Dumping),
(BackupStatus::Queued, BackupStatus::Failed),
(BackupStatus::Dumping, BackupStatus::Uploading),
(BackupStatus::Dumping, BackupStatus::Failed),
(BackupStatus::Uploading, BackupStatus::Completed),
(BackupStatus::Uploading, BackupStatus::Failed),
(BackupStatus::Completed, BackupStatus::Deleting),
(BackupStatus::Failed, BackupStatus::Deleting),
];
assert!(
transitions
.into_iter()
.all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_some() })
);
}
#[test]
fn status_transition_rejects_invalid_edges() {
let transitions = [
(BackupStatus::Queued, BackupStatus::Completed),
(BackupStatus::Completed, BackupStatus::Dumping),
(BackupStatus::Deleting, BackupStatus::Failed),
(BackupStatus::Deleting, BackupStatus::Completed),
];
assert!(
transitions
.into_iter()
.all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_none() })
);
}
#[test]
@@ -1,7 +1,9 @@
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块。
mod audit;
mod backup;
mod plugins;
mod provider_credentials;
mod quota_forecast;
mod quota_forecast_sampling;
mod retention;
@@ -0,0 +1,12 @@
use gateway_admin::model::retention::{RetentionPolicy, RetentionTarget};
#[test]
fn retention_policy_rejects_invalid_windows_and_preserves_independent_windows() {
for (usage, ops, audit) in [(30, 1, 1), (31, 0, 1), (31, 1, 0)] {
assert!(RetentionPolicy::try_new(usage, ops, audit).is_err());
}
let policy = RetentionPolicy::try_new(31, 7, 90).unwrap();
assert_eq!(policy.days(RetentionTarget::ModelRequests), 31);
assert_eq!(policy.days(RetentionTarget::OpsEvents), 7);
assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90);
}
@@ -95,6 +95,44 @@ impl ExtensionSetLease for Lease {
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for Fixture {
async fn runtime_diagnostics(
&self,
_: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
_: Option<u64>,
_: Option<&gateway_core::runtime::extensions::ExtensionSetReference>,
) -> Option<
std::collections::BTreeMap<
String,
gateway_admin::model::plugins::instances::PluginInstanceRuntime,
>,
> {
None
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginStateLifecycle for Fixture {
async fn activate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::instances::PluginInstance,
) -> Result<(), AdminError> {
Ok(())
}
async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) {
panic!("unexpected instance drain")
}
async fn migrate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: gateway_admin::model::plugins::state::PluginStateTransition,
) -> Result<(), AdminError> {
panic!("unexpected state migration")
}
}
#[async_trait]
impl PluginPreparation for Fixture {
async fn configuration_ready(
@@ -242,7 +242,10 @@ impl PluginPreparation for LifecycleFixture {
Arc::new(Lease),
))
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for LifecycleFixture {
async fn runtime_diagnostics(
&self,
_: &PluginInstanceSnapshot,
@@ -251,7 +254,10 @@ impl PluginPreparation for LifecycleFixture {
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
self.data.lock().unwrap().diagnostics.clone()
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginStateLifecycle for LifecycleFixture {
async fn activate_state(
&self,
_: &ExtensionSetReference,
@@ -163,6 +163,44 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts {
async fn runtime_diagnostics(
&self,
_: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
_: Option<u64>,
_: Option<&gateway_core::runtime::extensions::ExtensionSetReference>,
) -> Option<
std::collections::BTreeMap<
String,
gateway_admin::model::plugins::instances::PluginInstanceRuntime,
>,
> {
None
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts {
async fn activate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::instances::PluginInstance,
) -> Result<(), AdminError> {
panic!("unexpected state activation")
}
async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) {
panic!("unexpected instance drain")
}
async fn migrate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: gateway_admin::model::plugins::state::PluginStateTransition,
) -> Result<(), AdminError> {
panic!("unexpected state migration")
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts {
async fn configuration_ready(
@@ -605,6 +605,27 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts {
async fn activate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::instances::PluginInstance,
) -> Result<(), AdminError> {
Ok(())
}
async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) {
panic!("unexpected instance drain")
}
async fn migrate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: gateway_admin::model::plugins::state::PluginStateTransition,
) -> Result<(), AdminError> {
panic!("unexpected state migration")
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts {
async fn configuration_ready(
@@ -627,7 +648,10 @@ impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts {
) -> Result<gateway_core::runtime::extensions::ExtensionSetReference, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts {
async fn runtime_diagnostics(
&self,
snapshot: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
@@ -23,7 +23,7 @@ use crate::engine::{
CommitRequirement, ContinuationAttempt, CoordinatedEvent, EngineError, ExecutionOutcome,
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFailureObservation,
ModelRequestFinalization, ModelRequestId, NewModelRequest, ProviderAccountStateOwner,
ProviderAttemptOutcome, RequestAttemptContext, UpstreamSendState,
RequestAttemptContext, UpstreamSendState,
};
use crate::error::{
ContinuationRecoveryDisposition, GatewayError, GatewayErrorKind, ProviderError,
@@ -356,7 +356,7 @@ where
image_generation_requested,
last_retryable_failure: None,
last_retryable_failure_events: Vec::new(),
provider_attempt_outcomes: Vec::new(),
last_observed_provider: None,
pending_terminal_failure: None,
};
@@ -485,7 +485,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
last_retryable_failure: Option<ProviderError>,
/// 与 `last_retryable_failure` 同属一个 attempt 的原始失败批次。
last_retryable_failure_events: Vec<ProviderEvent>,
provider_attempt_outcomes: Vec<ProviderAttemptOutcome>,
last_observed_provider: Option<crate::identity::ProviderKind>,
/// 原子失败批次已交给协议层、但尚待下游提交后收敛的原 Provider 错误。
pending_terminal_failure: Option<PendingTerminalFailure>,
}
@@ -719,14 +719,6 @@ where
.unwrap_or(Decimal::ZERO)
}
/// 返回截至当前已完成的实际上游调用结果。
///
/// 调用方可保存已消费下标;该切片在会话生命周期内只追加、不改写。
#[must_use]
pub fn provider_attempt_outcomes(&self) -> &[ProviderAttemptOutcome] {
&self.provider_attempt_outcomes
}
/// 返回最终选中 attempt 已公开给协议层的安全响应头。
#[must_use]
pub fn response_headers(&self) -> &[ProviderResponseHeader] {
@@ -1176,7 +1168,7 @@ where
| ProviderErrorKind::ConcurrencyQueueTimeout
) && error.send_state() == UpstreamSendState::NotSent)
{
self.record_provider_failure(candidate.provider().clone(), error.kind());
self.record_provider_failure(candidate.provider().clone());
}
self.finish_provider_error(&error).await?;
return Err(provider_engine_error(error));
@@ -1185,7 +1177,7 @@ where
};
if !stream.metadata().confirms(&candidate) {
drop(stream);
self.record_provider_failure(candidate.provider().clone(), ProviderErrorKind::Protocol);
self.record_provider_failure(candidate.provider().clone());
let error = GatewayError::new(
GatewayErrorKind::Internal,
"provider metadata did not match the frozen candidate",
@@ -1435,7 +1427,7 @@ where
self.observe_websocket_response(event, websocket_attempt.clone());
}
}
self.record_provider_failure(current.metadata.provider().clone(), error.kind());
self.record_provider_failure(current.metadata.provider().clone());
// attempt_send_state 是本 attempt 自身的发送事实;共享 effect 单独作为
// 一票否决的重试门。持久化与终态用请求级水位,不能把早先 Provider attempt
// 的 sent 传染给当前 attempt,但任何已观测外部副作用都必须阻止重放。
@@ -1619,9 +1611,9 @@ where
self.observation.observe_event(fact);
}
}
// 普通 clone 只保留稳定事实;原始 wire/HTTP response 由 request-local
// 所有权保留到下一次 attempt 成功,或最终空选路时返回客户端。
let persistence_error = self.request_persisted.then(|| error.clone());
// 原始 wire/HTTP response 由 request-local 所有权保留到下一次 attempt
// 成功,或最终空选路时返回客户端;持久化只取得稳定事实快照。
let persistence_error = self.request_persisted.then(|| error.stable_snapshot());
if same_account_retry {
let account = current.metadata.provider_account_id().clone();
self.credential_recovery_attempted_accounts
@@ -2103,11 +2095,7 @@ where
.current
.as_ref()
.map(|current| current.metadata.provider().clone())
.or_else(|| {
self.provider_attempt_outcomes
.last()
.map(|outcome| outcome.provider_kind().clone())
});
.or_else(|| self.last_observed_provider.clone());
let observer = self.request_observation.clone();
let observation = observer.as_ref().map(|observer| {
observer.finalization(
@@ -2248,21 +2236,12 @@ where
.as_ref()
.map(|current| current.metadata.provider().clone());
if let Some(provider_kind) = provider_kind {
self.provider_attempt_outcomes
.push(ProviderAttemptOutcome::Succeeded { provider_kind });
self.last_observed_provider = Some(provider_kind);
}
}
fn record_provider_failure(
&mut self,
provider_kind: crate::identity::ProviderKind,
error_kind: ProviderErrorKind,
) {
self.provider_attempt_outcomes
.push(ProviderAttemptOutcome::Failed {
provider_kind,
error_kind,
});
fn record_provider_failure(&mut self, provider_kind: crate::identity::ProviderKind) {
self.last_observed_provider = Some(provider_kind);
}
}
@@ -38,7 +38,8 @@ use crate::engine::nested::{
NestedModelExecutionRequest,
};
use crate::engine::observation::{
FrozenRequestObservationContext, RequestObservationDispatch, RequestObserverExtensionIndex,
FrozenRequestObservationContext, RequestObservationDispatch, RequestObservationScope,
RequestObserverExtensionIndex,
};
use crate::engine::policy::{
ModelRouteDecision, RequestPolicyContext, RequestPolicyExtensionIndex,
@@ -1100,8 +1101,10 @@ impl DefaultExecutionService {
FrozenRequestObservationContext::new(
request_id.clone(),
request.client.snapshot.revision(),
request.client.policy.key_id().clone(),
account_group_ids.clone(),
RequestObservationScope::new(
request.client.policy.key_id().clone(),
account_group_ids.clone(),
),
request.operation.kind(),
request.target.public_model().cloned(),
authorization.extension_scope.clone(),
@@ -2097,7 +2100,7 @@ impl DefaultExecutionService {
provider_kind: observed.provider_kind.clone(),
account_id: observed.account_id.clone(),
upstream_model_id: observed.upstream_model.clone(),
error: provider_error.clone(),
error: provider_error.stable_snapshot(),
latency,
})
.await
@@ -216,7 +216,7 @@ impl MiddlewareRequest {
effective = effective.require(*feature);
}
operation
.with_middleware_requirements(original, effective)
.with_inherited_capability_requirements(effective)
.map_err(|_| MiddlewareError::InvalidState)
}
+1 -36
View File
@@ -34,9 +34,7 @@ use crate::account::{
ProviderAccountId,
};
use crate::engine::continuation::{ContinuationBinding, NativeContinuationPin};
use crate::error::{
GatewayError, ProviderConnectionObservation, ProviderError, ProviderErrorKind, StoreError,
};
use crate::error::{GatewayError, ProviderConnectionObservation, ProviderError, StoreError};
use crate::event::ProviderEvent;
use crate::identity::ProviderKind;
use crate::lifecycle::CancellationToken;
@@ -123,39 +121,6 @@ impl AttemptTrigger {
}
}
/// 一次实际上游调用的诊断结果。
///
/// 该事实只描述调用结果,不参与跨请求的路由屏蔽。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderAttemptOutcome {
/// 上游流自然完成且通过 canonical event 序列校验。
Succeeded { provider_kind: ProviderKind },
/// 上游打开或流式阶段返回了稳定 Provider 错误。
Failed {
provider_kind: ProviderKind,
error_kind: ProviderErrorKind,
},
}
impl ProviderAttemptOutcome {
/// 返回本次调用实际归属的 Provider。
#[must_use]
pub const fn provider_kind(&self) -> &ProviderKind {
match self {
Self::Succeeded { provider_kind } | Self::Failed { provider_kind, .. } => provider_kind,
}
}
/// 成功返回 `None`,失败返回稳定 Provider 错误分类。
#[must_use]
pub const fn error_kind(&self) -> Option<ProviderErrorKind> {
match self {
Self::Succeeded { .. } => None,
Self::Failed { error_kind, .. } => Some(*error_kind),
}
}
}
/// 单次 attempt 的账号选择与账号绑定状态事实。
///
/// `required_account` 用于管理端 connection test,或请求局部的同账号恢复 attempt;
@@ -66,6 +66,35 @@ impl WebSocketResponseAttempt {
}
}
/// 请求观察使用的冻结 Client Key 与账号组范围。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestObservationScope {
client_key_id: ClientApiKeyId,
account_group_ids: Arc<[AccountGroupId]>,
}
impl RequestObservationScope {
#[must_use]
pub fn new(client_key_id: ClientApiKeyId, mut account_group_ids: Vec<AccountGroupId>) -> Self {
account_group_ids.sort();
account_group_ids.dedup();
Self {
client_key_id,
account_group_ids: account_group_ids.into(),
}
}
#[must_use]
pub const fn client_key_id(&self) -> &ClientApiKeyId {
&self.client_key_id
}
#[must_use]
pub fn account_group_ids(&self) -> &[AccountGroupId] {
&self.account_group_ids
}
}
/// 实际上游 WebSocket 响应事件的只读观察。
///
/// Key 与账号组只用于 Runtime 匹配冻结绑定,不得进入插件 wire;`wire` 保留策略加工前
@@ -75,8 +104,7 @@ pub struct WebSocketResponseObservation {
event_id: String,
request_id: ModelRequestId,
config_revision: ConfigRevision,
client_key_id: Option<ClientApiKeyId>,
account_group_ids: Arc<[AccountGroupId]>,
client_scope: RequestObservationScope,
extension_scope: ExtensionCallScope,
operation: OperationKind,
requested_model: Option<PublicModelId>,
@@ -86,11 +114,12 @@ pub struct WebSocketResponseObservation {
}
impl WebSocketResponseObservation {
/// 创建一个尚未附加冻结 Key/组/公开模型范围的实际上游事件。
/// 创建一个已附加冻结 Key/组范围、尚未附加公开模型范围的实际上游事件。
#[must_use]
pub fn new(
request_id: ModelRequestId,
config_revision: ConfigRevision,
client_scope: RequestObservationScope,
operation: OperationKind,
attempt: WebSocketResponseAttempt,
sequence: u64,
@@ -104,8 +133,7 @@ impl WebSocketResponseObservation {
),
request_id,
config_revision,
client_key_id: None,
account_group_ids: Arc::from([]),
client_scope,
extension_scope: ExtensionCallScope::default(),
operation,
requested_model: None,
@@ -115,19 +143,6 @@ impl WebSocketResponseObservation {
}
}
#[must_use]
pub fn with_client_scope(
mut self,
client_key_id: ClientApiKeyId,
mut account_group_ids: Vec<AccountGroupId>,
) -> Self {
account_group_ids.sort();
account_group_ids.dedup();
self.client_key_id = Some(client_key_id);
self.account_group_ids = account_group_ids.into();
self
}
#[must_use]
pub fn with_requested_model(mut self, model: PublicModelId) -> Self {
self.requested_model = Some(model);
@@ -166,13 +181,13 @@ impl WebSocketResponseObservation {
}
#[must_use]
pub const fn client_key_id(&self) -> Option<&ClientApiKeyId> {
self.client_key_id.as_ref()
pub const fn client_key_id(&self) -> &ClientApiKeyId {
self.client_scope.client_key_id()
}
#[must_use]
pub fn account_group_ids(&self) -> &[AccountGroupId] {
&self.account_group_ids
self.client_scope.account_group_ids()
}
#[must_use]
@@ -227,8 +242,7 @@ pub struct RequestObservation {
event_id: String,
request_id: ModelRequestId,
config_revision: ConfigRevision,
client_key_id: Option<ClientApiKeyId>,
account_group_ids: Arc<[AccountGroupId]>,
client_scope: RequestObservationScope,
extension_scope: ExtensionCallScope,
operation: OperationKind,
requested_model: Option<PublicModelId>,
@@ -251,11 +265,12 @@ pub struct RequestObservation {
}
impl RequestObservation {
/// 创建一个不带 Provider、模型、用量或错误详情的最终事实。
/// 创建一个带冻结 Key/组范围、不带 Provider、模型、用量或错误详情的最终事实。
#[must_use]
pub fn new(
request_id: ModelRequestId,
config_revision: ConfigRevision,
client_scope: RequestObservationScope,
operation: OperationKind,
outcome: RequestObservationOutcome,
send_state: UpstreamSendState,
@@ -265,8 +280,7 @@ impl RequestObservation {
event_id: format!("{}:terminal", request_id.as_str()),
request_id,
config_revision,
client_key_id: None,
account_group_ids: Arc::from([]),
client_scope,
extension_scope: ExtensionCallScope::default(),
operation,
requested_model: None,
@@ -289,19 +303,6 @@ impl RequestObservation {
}
}
#[must_use]
pub fn with_client_scope(
mut self,
client_key_id: ClientApiKeyId,
mut account_group_ids: Vec<AccountGroupId>,
) -> Self {
account_group_ids.sort();
account_group_ids.dedup();
self.client_key_id = Some(client_key_id);
self.account_group_ids = account_group_ids.into();
self
}
#[must_use]
pub fn with_requested_model(mut self, model: PublicModelId) -> Self {
self.requested_model = Some(model);
@@ -414,13 +415,13 @@ impl RequestObservation {
}
#[must_use]
pub const fn client_key_id(&self) -> Option<&ClientApiKeyId> {
self.client_key_id.as_ref()
pub const fn client_key_id(&self) -> &ClientApiKeyId {
self.client_scope.client_key_id()
}
#[must_use]
pub fn account_group_ids(&self) -> &[AccountGroupId] {
&self.account_group_ids
self.client_scope.account_group_ids()
}
#[must_use]
@@ -568,8 +569,7 @@ struct RequestObservationDispatchState {
pub(super) struct FrozenRequestObservationContext {
request_id: ModelRequestId,
config_revision: ConfigRevision,
client_key_id: ClientApiKeyId,
account_group_ids: Arc<[AccountGroupId]>,
client_scope: RequestObservationScope,
operation: OperationKind,
requested_model: Option<PublicModelId>,
extension_scope: ExtensionCallScope,
@@ -580,19 +580,15 @@ impl FrozenRequestObservationContext {
pub(super) fn new(
request_id: ModelRequestId,
config_revision: ConfigRevision,
client_key_id: ClientApiKeyId,
mut account_group_ids: Vec<AccountGroupId>,
client_scope: RequestObservationScope,
operation: OperationKind,
requested_model: Option<PublicModelId>,
extension_scope: ExtensionCallScope,
) -> Self {
account_group_ids.sort();
account_group_ids.dedup();
Self {
request_id,
config_revision,
client_key_id,
account_group_ids: account_group_ids.into(),
client_scope,
operation,
requested_model,
extension_scope,
@@ -622,8 +618,7 @@ impl RequestObservationDispatch {
event_id: self.event_id(),
request_id: self.state.context.request_id.clone(),
config_revision: self.state.context.config_revision,
client_key_id: Some(self.state.context.client_key_id.clone()),
account_group_ids: Arc::clone(&self.state.context.account_group_ids),
client_scope: self.state.context.client_scope.clone(),
extension_scope: self.state.context.extension_scope.clone(),
operation: self.state.context.operation,
requested_model: self.state.context.requested_model.clone(),
@@ -655,15 +650,12 @@ impl RequestObservationDispatch {
let mut observation = WebSocketResponseObservation::new(
self.state.context.request_id.clone(),
self.state.context.config_revision,
self.state.context.client_scope.clone(),
self.state.context.operation,
attempt,
sequence,
wire,
)
.with_client_scope(
self.state.context.client_key_id.clone(),
self.state.context.account_group_ids.to_vec(),
)
.with_extension_scope(self.state.context.extension_scope.clone());
if let Some(model) = self.state.context.requested_model.clone() {
observation = observation.with_requested_model(model);
@@ -700,8 +692,7 @@ impl RequestObservationDispatch {
event_id: self.event_id(),
request_id: self.state.context.request_id.clone(),
config_revision: self.state.context.config_revision,
client_key_id: Some(self.state.context.client_key_id.clone()),
account_group_ids: Arc::clone(&self.state.context.account_group_ids),
client_scope: self.state.context.client_scope.clone(),
extension_scope: self.state.context.extension_scope.clone(),
operation: self.state.context.operation,
requested_model: self.state.context.requested_model.clone(),
@@ -359,12 +359,6 @@ impl ProviderStream {
self
}
/// 返回本次 stream 是否需要原生响应转换。
#[must_use]
pub const fn has_native_response_translator(&self) -> bool {
self.native_response_translator.is_some()
}
/// 在 Core 已记录原始事实且完成 `BeforeTranslation` 后执行原生转换。
///
/// # Errors
+8 -11
View File
@@ -356,7 +356,7 @@ impl ProviderConnectionObservation {
/// 投影不能覆盖 [`ProviderError`] 中用于诊断和记账的真实上游事实。
///
/// 该值不属于稳定诊断事实,不能进入日志或持久化。它刻意不实现 [`Clone`];
/// [`ProviderError`] 的普通 clone 会丢弃它,只有最终失败的原对象才能把响应交给
/// [`ProviderError::stable_snapshot`] 会丢弃它,只有最终失败的原对象才能把响应交给
/// 原客户端协议,或由认证管理端的账号连接测试显式复制到请求局部结果。
#[derive(PartialEq, Eq)]
pub struct ClientVisibleUpstreamResponse {
@@ -577,11 +577,6 @@ impl ProviderError {
self
}
/// 原地标记 Provider 已证明本次失败可从已交付的状态检查点安全恢复。
pub const fn set_replay_safe(&mut self) {
self.replay_safe = true;
}
/// 允许 Core 仅在客户端尚未收到任何事件时执行一次受预算约束的换号恢复。
///
/// 该标记不证明上游未执行,也不等价于 [`Self::with_replay_safe`];它只表达
@@ -850,8 +845,8 @@ impl ProviderError {
/// 取走只供本次重试/提交决策使用的原子客户端事件。
///
/// Core 必须在克隆错误、记录中间失败或构造终态前调用本方法,避免原始 wire
/// 进入诊断或持久化对象。
/// Core 必须在创建稳定快照、记录中间失败或构造终态前调用本方法,避免原始
/// wire 进入诊断或持久化对象。
#[must_use]
pub fn take_atomic_client_events(&mut self) -> Vec<ProviderEvent> {
self.atomic_client_events
@@ -864,10 +859,12 @@ impl ProviderError {
pub fn has_atomic_client_events(&self) -> bool {
self.atomic_client_events.is_some()
}
}
impl Clone for ProviderError {
fn clone(&self) -> Self {
/// 复制可安全进入诊断和持久化边界的稳定错误事实。
///
/// 请求局部的原始上游响应与尚未提交的客户端事件不会进入快照。
#[must_use]
pub fn stable_snapshot(&self) -> Self {
Self {
kind: self.kind,
send_state: self.send_state,
+5 -17
View File
@@ -403,7 +403,7 @@ struct GeneratePayload {
protocol_payload: ProtocolPayload,
provider_session_state: Option<ProviderSessionState>,
source_requirements: Option<CapabilityRequirements>,
middleware_requirements: Option<(CapabilityRequirements, CapabilityRequirements)>,
inherited_capability_requirements: Option<CapabilityRequirements>,
}
impl GenerateRequest {
@@ -418,7 +418,7 @@ impl GenerateRequest {
protocol_payload,
provider_session_state: None,
source_requirements: None,
middleware_requirements: None,
inherited_capability_requirements: None,
}),
}
}
@@ -518,7 +518,7 @@ impl GenerateRequest {
}
let mut requirements = CapabilityRequirements::new(OperationKind::Generate)
.with_requested_output_tokens(self.max_output_tokens());
if let Some((_, inherited)) = &self.payload.middleware_requirements {
if let Some(inherited) = &self.payload.inherited_capability_requirements {
for feature in inherited.features() {
requirements = requirements.require(*feature);
}
@@ -873,9 +873,8 @@ pub enum Operation {
}
impl Operation {
pub(crate) fn with_middleware_requirements(
pub(crate) fn with_inherited_capability_requirements(
self,
original: CapabilityRequirements,
inherited: CapabilityRequirements,
) -> Result<Self, OperationError> {
let Self::Generate(mut request) = self else {
@@ -883,21 +882,10 @@ impl Operation {
field: "middleware capabilities",
});
};
Arc::make_mut(&mut request.payload).middleware_requirements = Some((original, inherited));
Arc::make_mut(&mut request.payload).inherited_capability_requirements = Some(inherited);
Ok(Self::Generate(request))
}
/// 请求转换前的需求仅供诊断,选路始终使用有效上游需求。
#[must_use]
pub fn original_capability_requirements(&self) -> CapabilityRequirements {
if let Self::Generate(request) = self
&& let Some((original, _)) = &request.payload.middleware_requirements
{
return original.clone();
}
self.capability_requirements()
}
/// 把协议正文编码为中间件可见的原始字节;不包含非 wire context 或会话状态。
///
/// # Errors
@@ -25,8 +25,7 @@ use gateway_core::engine::provider::{
use gateway_core::engine::{
AttemptContext, AttemptCoordinator, AttemptRecord, AttemptTransport, CommitRequirement,
ContinuationAttempt, EngineError, ExecutionOutcome, ExecutionStore, GatewayEngine,
IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest,
ProviderAttemptOutcome, RecoveryReport,
IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest, RecoveryReport,
};
use gateway_core::error::{
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, ContinuationFailure,
@@ -838,12 +837,6 @@ fn success_updates_one_model_request_and_persists_usage() {
block_on(session.commit_downstream(Some(200))).expect("commit response");
assert!(session.is_finalized());
assert_eq!(
session.provider_attempt_outcomes(),
&[ProviderAttemptOutcome::Succeeded {
provider_kind: ProviderKind::new("openai").expect("provider"),
}]
);
let state = store.state.lock().expect("store lock");
assert_eq!(state.created, 1);
assert_eq!(state.attempts.len(), 1);
@@ -2150,13 +2143,6 @@ fn required_account_disables_account_retry_after_stream_creation() {
error,
gateway_core::engine::EngineError::Provider(_)
));
assert_eq!(
session.provider_attempt_outcomes(),
&[ProviderAttemptOutcome::Failed {
provider_kind: ProviderKind::new("openai").expect("provider"),
error_kind: ProviderErrorKind::Unavailable,
}]
);
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1);
assert_eq!(provider.scripts.lock().expect("scripts lock").len(), 1);
let state = store.state.lock().expect("store lock");
@@ -3911,13 +3897,6 @@ fn structural_event_before_replay_safe_failure_should_switch_account_before_comm
assert_eq!(first.into_provider_events().len(), 1);
block_on(session.commit_downstream(Some(200))).expect("commit first event");
block_on(session.next_event()).expect_err("committed stream failure must not be replayed");
assert_eq!(
session.provider_attempt_outcomes(),
&[ProviderAttemptOutcome::Failed {
provider_kind: ProviderKind::new("openai").expect("provider"),
error_kind: ProviderErrorKind::Transport,
}]
);
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1);
let state = store.state.lock().expect("store lock");
assert_eq!(state.commits, 1);
@@ -4000,7 +3979,6 @@ fn no_eligible_account_before_stream_records_failure_without_fabricating_an_atte
gateway_core::engine::EngineError::Provider(ref error)
if error.kind() == kind
));
assert!(session.provider_attempt_outcomes().is_empty());
assert!(session.is_finalized());
block_on(session.record_client_status(status)).expect("HTTP error status");
block_on(session.cancel_and_finalize()).expect("repeated finalization");
@@ -4265,7 +4243,6 @@ fn deadline_during_preparation_records_failure_without_an_upstream_attempt() {
block_on(session.next_event()),
Err(EngineError::Deadline)
));
assert!(session.provider_attempt_outcomes().is_empty());
assert!(session.is_finalized());
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
let state = store.state.lock().unwrap();
@@ -4534,7 +4511,7 @@ fn charged_terminal_write_resumes_without_replacing_success_failure_or_send_fact
}
#[test]
fn deadline_after_commit_is_incomplete_without_provider_circuit_failure() {
fn deadline_after_commit_is_persisted_as_incomplete() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::HangingStream {
@@ -4565,16 +4542,13 @@ fn deadline_after_commit_is_incomplete_without_provider_circuit_failure() {
let error = block_on(session.next_event()).expect_err("deadline elapses mid-stream");
assert!(matches!(error, EngineError::Deadline));
// 网关自身请求预算到期不是上游超时;已在交付中的长流集中到期
// 不得作为 provider Timeout 计入熔断。
assert!(session.provider_attempt_outcomes().is_empty());
let state = store.state.lock().expect("store lock");
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
assert!(state.finalizations[0].committed);
}
#[test]
fn deadline_before_first_event_records_no_provider_circuit_failure() {
fn deadline_before_first_event_is_persisted_as_failed_attempt() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::HangingStream {
@@ -4594,7 +4568,6 @@ fn deadline_before_first_event_records_no_provider_circuit_failure() {
let error = block_on(session.next_event()).expect_err("deadline elapses before first event");
assert!(matches!(error, EngineError::Deadline));
assert!(session.provider_attempt_outcomes().is_empty());
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
@@ -791,7 +791,7 @@ impl Provider for RetryTestProvider {
);
Ok(ProviderStream::new(
metadata,
futures::stream::iter([Err(self.error.clone())]),
futures::stream::iter([Err(self.error.stable_snapshot())]),
(),
))
}
@@ -2457,10 +2457,7 @@ fn final_observation_is_emitted_once_for_success_rejection_and_detached_cancella
}
fn assert_observation_scope(observation: &RequestObservation) {
assert_eq!(
observation.client_key_id().map(ClientApiKeyId::as_str),
Some("key_start_test")
);
assert_eq!(observation.client_key_id().as_str(), "key_start_test");
assert_eq!(
observation
.account_group_ids()
@@ -202,10 +202,6 @@ fn capability_declaration_preserves_original_semantics_and_recomputes_upstream_n
let operation = request
.apply_capabilities(body_operation(&converted))
.unwrap();
assert_eq!(
operation.original_capability_requirements(),
body_operation(&original).capability_requirements()
);
assert_eq!(
operation.capability_requirements().features(),
&[Feature::Tools, Feature::Reasoning].into()
+12 -12
View File
@@ -27,15 +27,15 @@ fn provider_error_debug_should_not_print_classified_upstream_values() {
}
#[test]
fn classified_provider_diagnostic_survives_clone_without_entering_debug() {
fn classified_provider_diagnostic_survives_stable_snapshot_without_entering_debug() {
let message = "OpenAI WebSocket closed before terminal response (close code 1000)";
let error = ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
.with_diagnostic(
ProviderDiagnostic::new(message)
.with_classification("receive", "closed_before_terminal"),
);
let cloned = error.clone();
let gateway = GatewayError::from_provider(&cloned);
let snapshot = error.stable_snapshot();
let gateway = GatewayError::from_provider(&snapshot);
assert_eq!(
gateway.diagnostic().map(ProviderDiagnostic::as_str),
@@ -54,14 +54,14 @@ fn classified_provider_diagnostic_survives_clone_without_entering_debug() {
}
#[test]
fn raw_upstream_error_survives_clone_without_entering_debug() {
fn raw_upstream_error_survives_stable_snapshot_without_entering_debug() {
let raw = r#"{"error":{"message":"verbatim upstream marker"}}"#;
let error = ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent)
.with_raw_upstream_error(RawUpstreamError::new(raw));
let cloned = error.clone();
let snapshot = error.stable_snapshot();
assert_eq!(
cloned.raw_upstream_error().map(RawUpstreamError::as_str),
snapshot.raw_upstream_error().map(RawUpstreamError::as_str),
Some(raw)
);
assert!(!format!("{error:?}").contains("verbatim upstream marker"));
@@ -112,7 +112,7 @@ fn provider_error_atomic_client_events_should_be_take_only_and_debug_redacted()
}
#[test]
fn provider_error_clone_should_drop_request_local_upstream_response() {
fn provider_error_stable_snapshot_should_drop_request_local_upstream_response() {
let marker = Bytes::from_static(b"raw-client-response-only");
let error = ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
.with_client_visible_upstream_response(
@@ -127,7 +127,7 @@ fn provider_error_clone_should_drop_request_local_upstream_response() {
)]),
);
let cloned = error.clone();
let snapshot = error.stable_snapshot();
assert_eq!(
error
@@ -135,13 +135,13 @@ fn provider_error_clone_should_drop_request_local_upstream_response() {
.map(ClientVisibleUpstreamResponse::body),
Some(&marker)
);
assert!(cloned.client_visible_upstream_response().is_none());
assert!(snapshot.client_visible_upstream_response().is_none());
assert!(!format!("{error:?}").contains("raw-client-response-only"));
assert!(!format!("{error:?}").contains("opaque-header-value"));
}
#[test]
fn provider_error_clone_should_drop_atomic_client_events() {
fn provider_error_stable_snapshot_should_drop_atomic_client_events() {
let event = ProviderEvent::wire(
ProtocolWireEvent::json(
"openai",
@@ -152,10 +152,10 @@ fn provider_error_clone_should_drop_atomic_client_events() {
);
let error = ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
.with_atomic_client_events(vec![event]);
let cloned = error.clone();
let snapshot = error.stable_snapshot();
assert!(error.has_atomic_client_events());
assert!(!cloned.has_atomic_client_events());
assert!(!snapshot.has_atomic_client_events());
}
#[test]
+1
View File
@@ -10,6 +10,7 @@ pub mod plugin_distribution;
pub mod pricing;
pub mod process;
pub mod proxy_probe;
pub mod retention;
pub mod serve;
pub mod system_update;
pub mod workers;
@@ -0,0 +1,141 @@
//! 保留期任务的调度、单轮预算、取消与日志;删除事务由 Store 端口负责。
use std::{num::NonZeroU32, sync::Arc, time::Duration};
use chrono::Utc;
use gateway_admin::{model::retention::RetentionTarget, ports::retention::RetentionStore};
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDefinitionError, WorkerId,
WorkerKind, WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule,
WorkerTaskError,
};
#[derive(Debug, Clone, Copy)]
pub struct RetentionCycleBudget {
pub batch_rows: NonZeroU32,
pub max_batches: NonZeroU32,
pub max_duration: Duration,
pub batch_pause: Duration,
}
impl Default for RetentionCycleBudget {
fn default() -> Self {
Self {
batch_rows: NonZeroU32::new(5_000).unwrap_or(NonZeroU32::MIN),
max_batches: NonZeroU32::new(12).unwrap_or(NonZeroU32::MIN),
max_duration: Duration::from_secs(30),
batch_pause: Duration::from_millis(50),
}
}
}
pub fn worker(store: Arc<dyn RetentionStore>) -> Result<WorkerContribution, WorkerDefinitionError> {
// 保持稳定的租约身份,滚动升级期间仍与已有进程互斥。
let id = WorkerId::try_new(WorkerKind::Retention, "postgres")?;
let schedule = WorkerSchedule::try_new(
Duration::from_secs(60 * 60),
Duration::from_secs(1),
Duration::from_secs(60),
Duration::from_secs(15 * 60),
Duration::from_secs(5 * 60),
)?;
let lease = WorkerLeaseRequest::try_new(id.clone(), schedule.leader_lease_ttl())?;
Ok(WorkerContribution::Registration(
WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(RetentionTask::new(store, RetentionCycleBudget::default())),
},
)?,
))
}
pub struct RetentionTask {
store: Arc<dyn RetentionStore>,
budget: RetentionCycleBudget,
}
impl RetentionTask {
#[must_use]
pub fn new(store: Arc<dyn RetentionStore>, budget: RetentionCycleBudget) -> Self {
Self { store, budget }
}
async fn cleanup(&self, context: &WorkerCycleContext) -> Result<(), WorkerTaskError> {
let policy = self
.store
.load_policy()
.await
.map_err(|_| WorkerTaskError::safe("retention settings read failed"))?;
let now = Utc::now();
let started_at = tokio::time::Instant::now();
let mut targets = [
(RetentionTarget::ModelRequests, 0_u64, false),
(RetentionTarget::OpsEvents, 0, false),
(RetentionTarget::AdminAuditEvents, 0, false),
];
let mut batches = 0;
'cycles: loop {
for (target, deleted, complete) in &mut targets {
if *complete {
continue;
}
if batches >= self.budget.max_batches.get()
|| started_at.elapsed() >= self.budget.max_duration
|| context.cancellation().is_cancelled()
{
break 'cycles;
}
let count = self
.store
.purge_batch(*target, now, policy, self.budget.batch_rows)
.await
.map_err(|_| WorkerTaskError::safe("retention cleanup failed"))?;
batches += 1;
*deleted = deleted.saturating_add(count);
*complete = count < u64::from(self.budget.batch_rows.get());
if !*complete
&& batches < self.budget.max_batches.get()
&& !self.budget.batch_pause.is_zero()
{
let remaining = self
.budget
.max_duration
.saturating_sub(started_at.elapsed());
tokio::time::sleep(self.budget.batch_pause.min(remaining)).await;
}
}
if targets.iter().all(|(_, _, complete)| *complete) {
break;
}
}
tracing::info!(
model_requests = targets[0].1,
ops_events = targets[1].1,
admin_audit_events = targets[2].1,
batches,
budget_exhausted = targets.iter().any(|(_, _, complete)| !complete),
elapsed_milliseconds =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
"retention cycle completed"
);
Ok(())
}
}
impl ScheduledTask for RetentionTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
tokio::select! {
biased;
() = context.cancellation().cancelled() => Ok(()),
result = self.cleanup(&context) => result,
}
})
}
}
@@ -7,6 +7,7 @@ mod plugin_distribution;
mod pricing;
mod process;
mod proxy_probe;
mod retention;
mod serve;
mod support;
mod system_update;
@@ -0,0 +1,146 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_admin::{
model::retention::{RetentionPolicy, RetentionTarget},
ports::{
retention::RetentionStore,
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::{
lifecycle::CancellationToken,
task::{ScheduledTask, WorkerCycleContext, WorkerId, WorkerKind},
};
use gateway_host::retention::{RetentionCycleBudget, RetentionTask};
use std::{
num::NonZeroU32,
sync::{Arc, Mutex},
time::Duration,
};
#[derive(Default)]
struct Store {
calls: Mutex<Vec<RetentionTarget>>,
fail: bool,
full: bool,
block_load: bool,
}
#[async_trait]
impl RetentionStore for Store {
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy> {
if self.block_load {
std::future::pending::<()>().await;
}
Ok(RetentionPolicy::try_new(31, 7, 90).unwrap())
}
async fn purge_batch(
&self,
target: RetentionTarget,
_: DateTime<Utc>,
_: RetentionPolicy,
limit: NonZeroU32,
) -> AdminStoreResult<u64> {
self.calls.lock().unwrap().push(target);
if self.fail {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"retention",
"fixture",
));
}
Ok(if self.full { u64::from(limit.get()) } else { 0 })
}
}
fn context(cancellation: CancellationToken) -> WorkerCycleContext {
WorkerCycleContext::new(
WorkerId::try_new(WorkerKind::Retention, "postgres").unwrap(),
None,
cancellation,
)
}
fn budget() -> RetentionCycleBudget {
RetentionCycleBudget {
batch_rows: NonZeroU32::new(2).unwrap(),
max_batches: NonZeroU32::new(4).unwrap(),
max_duration: Duration::from_secs(1),
batch_pause: Duration::ZERO,
}
}
#[tokio::test]
async fn full_tables_rotate_until_batch_budget_is_exhausted() {
let store = Arc::new(Store {
full: true,
..Store::default()
});
RetentionTask::new(store.clone(), budget())
.run_cycle(context(CancellationToken::new()))
.await
.unwrap();
assert_eq!(
*store.calls.lock().unwrap(),
[
RetentionTarget::ModelRequests,
RetentionTarget::OpsEvents,
RetentionTarget::AdminAuditEvents,
RetentionTarget::ModelRequests
]
);
}
#[tokio::test]
async fn empty_tables_are_not_revisited_and_errors_stop_the_cycle() {
let store = Arc::new(Store::default());
RetentionTask::new(store.clone(), budget())
.run_cycle(context(CancellationToken::new()))
.await
.unwrap();
assert_eq!(store.calls.lock().unwrap().len(), 3);
let store = Arc::new(Store {
fail: true,
..Store::default()
});
assert!(
RetentionTask::new(store.clone(), budget())
.run_cycle(context(CancellationToken::new()))
.await
.is_err()
);
assert_eq!(store.calls.lock().unwrap().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn duration_budget_stops_cleanup_during_batch_pause() {
let store = Arc::new(Store {
full: true,
..Store::default()
});
let mut budget = budget();
budget.batch_pause = Duration::from_secs(2);
RetentionTask::new(store.clone(), budget)
.run_cycle(context(CancellationToken::new()))
.await
.unwrap();
assert_eq!(store.calls.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn cancellation_interrupts_settings_read_before_any_delete() {
let store = Arc::new(Store {
block_load: true,
..Store::default()
});
let token = CancellationToken::new();
let task = RetentionTask::new(store.clone(), budget());
let run = task.run_cycle(context(token.clone()));
tokio::pin!(run);
tokio::select! {
result = &mut run => panic!("unexpected completion: {result:?}"),
() = tokio::task::yield_now() => token.cancel(),
}
run.await.unwrap();
assert!(store.calls.lock().unwrap().is_empty());
}
@@ -456,9 +456,7 @@ fn wire_observation(observation: &RequestObservation) -> RequestCompleted {
request_id: observation.request_id().as_str().to_owned(),
config_revision: observation.config_revision().get(),
operation: observation.operation().as_str().to_owned(),
client_key_id: observation
.client_key_id()
.map(|key| key.as_str().to_owned()),
client_key_id: Some(observation.client_key_id().as_str().to_owned()),
account_id: observation
.account_id()
.map(|account| account.as_str().to_owned()),
@@ -102,12 +102,12 @@ impl BindingScope {
pub(crate) fn matches_route(&self, input: &ModelRouteInput) -> bool {
// Router 执行时 Provider 尚未产生;带 Provider 条件的绑定在编译时拒绝。
!self.has_provider_condition()
&& self.matches_identity(Some(input.client_key_id()), input.account_group_ids())
&& self.matches_identity(input.client_key_id(), input.account_group_ids())
&& self.matches_model(Some(input.requested_model().as_str()))
}
pub(crate) fn matches_schedule(&self, input: &AccountScheduleInput) -> bool {
self.matches_identity(Some(input.client_key_id()), input.account_group_ids())
self.matches_identity(input.client_key_id(), input.account_group_ids())
&& self.matches_provider(Some(input.provider()))
&& self.matches_model(input.model())
}
@@ -119,7 +119,7 @@ impl BindingScope {
provider: Option<&ProviderKind>,
model: Option<&str>,
) -> bool {
self.matches_identity(Some(client_key), account_groups)
self.matches_identity(client_key, account_groups)
&& self.matches_provider(provider)
&& self.matches_model(model)
}
@@ -133,11 +133,10 @@ impl BindingScope {
fn matches_identity(
&self,
client_key: Option<&ClientApiKeyId>,
client_key: &ClientApiKeyId,
account_groups: &[AccountGroupId],
) -> bool {
(self.client_keys.is_empty()
|| client_key.is_some_and(|key| self.client_keys.contains(key)))
(self.client_keys.is_empty() || self.client_keys.contains(client_key))
&& (self.account_groups.is_empty()
|| account_groups
.iter()
@@ -904,7 +904,10 @@ impl PluginPreparation for PluginRuntime {
self.prepare_snapshot(snapshot, Some(required_revision))
.await
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for PluginRuntime {
async fn runtime_diagnostics(
&self,
snapshot: &PluginInstanceSnapshot,
@@ -954,7 +957,10 @@ impl PluginPreparation for PluginRuntime {
.collect(),
)
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginStateLifecycle for PluginRuntime {
async fn activate_state(
&self,
prepared: &ExtensionSetReference,
@@ -14,8 +14,8 @@ use gateway_core::{
engine::{
ModelRequestId, ModelRequestTimings,
observation::{
RequestObservation, RequestObservationOutcome, WebSocketResponseAttempt,
WebSocketResponseObservation,
RequestObservation, RequestObservationOutcome, RequestObservationScope,
WebSocketResponseAttempt, WebSocketResponseObservation,
},
},
error::GatewayErrorKind,
@@ -142,6 +142,20 @@ async fn setup_instances(
}
fn observation(id: &str, outcome: RequestObservationOutcome) -> RequestObservation {
observation_with_scope(
id,
outcome,
"client-key-observed",
vec![AccountGroupId::new("grp_11111111111111111111111111111111").unwrap()],
)
}
fn observation_with_scope(
id: &str,
outcome: RequestObservationOutcome,
client_key_id: &str,
account_group_ids: Vec<AccountGroupId>,
) -> RequestObservation {
let send_state = if outcome == RequestObservationOutcome::Rejected {
UpstreamSendState::NotSent
} else {
@@ -150,6 +164,10 @@ fn observation(id: &str, outcome: RequestObservationOutcome) -> RequestObservati
RequestObservation::new(
ModelRequestId::new(id).unwrap(),
ConfigRevision::new(1).unwrap(),
RequestObservationScope::new(
ClientApiKeyId::new(client_key_id).unwrap(),
account_group_ids,
),
OperationKind::Generate,
outcome,
send_state,
@@ -172,6 +190,10 @@ fn websocket_observation(
WebSocketResponseObservation::new(
ModelRequestId::new(id).unwrap(),
ConfigRevision::new(1).unwrap(),
RequestObservationScope::new(
ClientApiKeyId::new("client-key-observed").unwrap(),
vec![AccountGroupId::new("grp_11111111111111111111111111111111").unwrap()],
),
OperationKind::Generate,
WebSocketResponseAttempt::new(
ProviderKind::new("openai").unwrap(),
@@ -181,10 +203,6 @@ fn websocket_observation(
sequence,
ProtocolWireEvent::json("openai", Some("response.output_text.delta".into()), body).unwrap(),
)
.with_client_scope(
ClientApiKeyId::new("client-key-observed").unwrap(),
vec![AccountGroupId::new("grp_11111111111111111111111111111111").unwrap()],
)
.with_requested_model(PublicModelId::new("gpt-observed").unwrap())
}
@@ -640,8 +658,10 @@ async fn client_scope_filters_observations_and_exposes_only_the_key_identifier()
plan.dispatch(
generation.clone(),
observation("req_scope_match", RequestObservationOutcome::Rejected).with_client_scope(
ClientApiKeyId::new("client-key-observed").unwrap(),
observation_with_scope(
"req_scope_match",
RequestObservationOutcome::Rejected,
"client-key-observed",
vec![AccountGroupId::new(matching_group).unwrap()],
),
);
@@ -682,8 +702,7 @@ async fn client_scope_filters_observations_and_exposes_only_the_key_identifier()
] {
plan.dispatch(
generation.clone(),
observation(request_id, RequestObservationOutcome::Rejected)
.with_client_scope(ClientApiKeyId::new(key).unwrap(), groups),
observation_with_scope(request_id, RequestObservationOutcome::Rejected, key, groups),
);
}
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -1466,12 +1466,6 @@ impl CapabilitiesNext {
ProtocolPayload::json_object("openai", body).unwrap(),
));
let operation = request.apply_capabilities(operation)?;
assert!(
operation
.original_capability_requirements()
.features()
.contains(&gateway_core::operation::Feature::JsonSchema)
);
assert!(
!operation
.capability_requirements()
@@ -268,7 +268,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat
.await
.unwrap();
let running = PluginPreparation::runtime_diagnostics(
let running = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(snapshot.config_revision.get()),
@@ -294,7 +294,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat
.await
.is_err()
);
let diagnostics = PluginPreparation::runtime_diagnostics(
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&failed,
Some(snapshot.config_revision.get()),
@@ -314,7 +314,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat
disabled.config_revision = Revision::new(3).unwrap();
disabled.instances[0].revision = Revision::new(3).unwrap();
disabled.instances[0].enabled = false;
let diagnostics = PluginPreparation::runtime_diagnostics(
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&disabled,
Some(snapshot.config_revision.get()),
@@ -344,7 +344,12 @@ async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() {
let diagnostics = tokio::time::timeout(
std::time::Duration::from_millis(100),
PluginPreparation::runtime_diagnostics(&runtime, &diagnostic_snapshot, None, None),
gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&diagnostic_snapshot,
None,
None,
),
)
.await
.expect("diagnostics must not wait for plugin startup")
@@ -375,7 +380,7 @@ async fn diagnostics_report_a_published_process_fault_without_plugin_details() {
.await
.expect("worker exit observed");
let diagnostics = PluginPreparation::runtime_diagnostics(
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(snapshot.config_revision.get()),
@@ -436,7 +441,7 @@ async fn restart_circuit_caps_short_lived_crashes_and_a_new_revision_recovers()
);
assert_eq!(startup_count(&marker), 3);
let diagnostics = PluginPreparation::runtime_diagnostics(
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(snapshot.config_revision.get()),
@@ -545,7 +550,7 @@ async fn restart_circuit_ignores_planned_generation_shutdown() {
.await
.expect("first generation");
let instance = &snapshot.instances[0];
PluginPreparation::quiesce_instance(
gateway_admin::ports::plugins::PluginStateLifecycle::quiesce_instance(
&runtime,
&instance.id,
&instance.artifact_sha256,
@@ -598,9 +603,11 @@ async fn restart_circuit_counts_exit_before_the_candidate_is_indexed() {
"open circuit must gate process spawn"
);
let diagnostics = PluginPreparation::runtime_diagnostics(&runtime, &snapshot, None, None)
.await
.expect("runtime diagnostics");
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime, &snapshot, None, None,
)
.await
.expect("runtime diagnostics");
assert_eq!(
diagnostics["instance-one"]
.failure
@@ -694,9 +701,11 @@ async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures()
snapshot.config_revision = Revision::new(5).unwrap();
snapshot.instances[1].revision = Revision::new(5).unwrap();
snapshot.instances[1].configuration = serde_json::json!({"revision":5});
let circuit = PluginPreparation::runtime_diagnostics(&runtime, &snapshot, None, None)
.await
.unwrap();
let circuit = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime, &snapshot, None, None,
)
.await
.unwrap();
assert_eq!(
circuit["instance-one"]
.failure
@@ -707,10 +716,14 @@ async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures()
let recovered = PluginPreparation::prepare(&runtime, snapshot.clone())
.await
.expect("an older failed instance is isolated during unrelated recovery");
let diagnostics =
PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(5), Some(&recovered))
.await
.unwrap();
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(5),
Some(&recovered),
)
.await
.unwrap();
assert_eq!(
diagnostics["instance-one"]
.failure
@@ -764,14 +777,15 @@ async fn restart_circuit_restores_an_older_failure_after_candidate_shutdown() {
tokio::time::timeout(Duration::from_secs(2), async {
loop {
let diagnostics = PluginPreparation::runtime_diagnostics(
&runtime,
&snapshot,
Some(1),
Some(&published),
)
.await
.expect("runtime diagnostics");
let diagnostics =
gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(1),
Some(&published),
)
.await
.expect("runtime diagnostics");
if diagnostics["instance-one"]
.failure
.as_ref()
@@ -791,10 +805,14 @@ async fn restart_circuit_restores_an_older_failure_after_candidate_shutdown() {
let recovered = PluginPreparation::prepare(&runtime, snapshot.clone())
.await
.expect("an older failed instance is isolated during unrelated recovery");
let diagnostics =
PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(3), Some(&recovered))
.await
.unwrap();
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(3),
Some(&recovered),
)
.await
.unwrap();
assert_eq!(
diagnostics["instance-one"]
.failure
@@ -876,10 +894,14 @@ async fn restoring_a_failed_plugin_keeps_other_instances_ready_and_reports_only_
.await
.expect("one failed plugin must not stop restoration");
assert!(generation.can_serve());
let diagnostics =
PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&generation))
.await
.unwrap();
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(1),
Some(&generation),
)
.await
.unwrap();
assert_eq!(
diagnostics["failed-plugin"].status,
PluginInstanceRuntimeStatus::PreparationFailed
@@ -919,10 +941,14 @@ async fn a_published_process_crash_preserves_the_serving_snapshot_and_other_plug
wait_until_unready(&generation).await;
assert!(generation.can_serve());
assert!(!generation.is_ready());
let diagnostics =
PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&generation))
.await
.unwrap();
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(1),
Some(&generation),
)
.await
.unwrap();
assert_eq!(
diagnostics["instance-one"].status,
PluginInstanceRuntimeStatus::Running
@@ -957,10 +983,14 @@ async fn an_incompatible_host_quarantines_the_plugin_with_a_specific_version_err
.unwrap();
assert!(restored.can_serve());
let snapshot = store.snapshot.lock().unwrap().clone();
let diagnostics =
PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&restored))
.await
.unwrap();
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
&snapshot,
Some(1),
Some(&restored),
)
.await
.unwrap();
let failure = diagnostics["instance-one"].failure.as_ref().unwrap();
assert!(
failure
@@ -315,6 +315,10 @@ async fn observation_callbacks_use_typed_state_crud_and_reject_undeclared_namesp
RequestObservation::new(
ModelRequestId::new("req_state").unwrap(),
ConfigRevision::new(1).unwrap(),
gateway_core::engine::observation::RequestObservationScope::new(
gateway_core::policy::ClientApiKeyId::new("client-key-state").unwrap(),
vec![],
),
OperationKind::Generate,
RequestObservationOutcome::Succeeded,
UpstreamSendState::Sent,
@@ -1,6 +1,7 @@
//! Admin 认证与设置 adapter。
use super::*;
use gateway_admin::model::audit::MutationAuditOperation;
pub(crate) struct AuthStoreAdapter {
pub(crate) security: postgres::PgAdminSecurityAuditRepository,
@@ -29,8 +30,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
) -> AdminStoreResult<gateway_admin::model::Revision> {
let audit = mutation_audit(
context,
"pricing.sync",
"model_pricing",
MutationAuditOperation::ModelPricingSync,
"models.dev",
vec!["synced".to_owned()],
);
@@ -49,8 +49,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
) -> AdminStoreResult<gateway_admin::model::Revision> {
let audit = mutation_audit(
context,
"pricing.update",
"model_pricing",
MutationAuditOperation::ModelPricingUpdate,
&command.provider,
command.models.clone(),
);
@@ -118,8 +117,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
},
audit: mutation_audit(
context,
"settings.replace",
"runtime_settings",
MutationAuditOperation::RuntimeSettingsReplace,
"1",
vec![
"provider_request_profiles_json".to_owned(),
@@ -181,12 +179,7 @@ impl AdminSettingsStoreAdapter {
admin_api_key,
mutation_audit(
context,
if exists {
"admin_api_key.replace"
} else {
"admin_api_key.delete"
},
"runtime_settings",
MutationAuditOperation::AdminApiKeyChanged { exists },
"1",
vec!["admin_api_key".to_owned()],
),
@@ -411,21 +404,9 @@ fn auth_audit_record(event: AdminAuditModel) -> AdminStoreResult<postgres::Admin
"config revision is outside the supported range",
)
})?;
let actor_kind = match event.actor_kind {
gateway_admin::model::auth::AuditActorKind::AdminSession => {
postgres::AdminAuditActorKind::AdminSession
}
gateway_admin::model::auth::AuditActorKind::AdminApiKey => {
postgres::AdminAuditActorKind::AdminApiKey
}
gateway_admin::model::auth::AuditActorKind::System => postgres::AdminAuditActorKind::System,
gateway_admin::model::auth::AuditActorKind::Anonymous => {
postgres::AdminAuditActorKind::Anonymous
}
};
Ok(postgres::AdminAuditEvent {
id: event.id,
actor_kind,
actor_kind: event.actor_kind.into(),
actor_admin_user_id: event.actor_admin_user_id,
actor_ref: event.actor_ref,
admin_request_id: event.request_id,
+7 -1
View File
@@ -10,6 +10,7 @@ pub struct StoreBundle {
admin_ports: AdminStorePorts,
core_ports: CoreStorePorts,
provider_ports: ProviderStorePorts,
retention: Arc<dyn gateway_admin::ports::retention::RetentionStore>,
worker_leader_lease: Arc<dyn WorkerLeaderLeasePort>,
health_probes: Vec<Arc<dyn HealthProbe>>,
worker_contributions: Vec<WorkerContribution>,
@@ -33,6 +34,11 @@ impl StoreBundle {
self.provider_ports.clone()
}
#[must_use]
pub fn retention(&self) -> Arc<dyn gateway_admin::ports::retention::RetentionStore> {
Arc::clone(&self.retention)
}
#[must_use]
pub fn worker_leader_lease(&self) -> Arc<dyn WorkerLeaderLeasePort> {
Arc::clone(&self.worker_leader_lease)
@@ -256,7 +262,6 @@ async fn connect(
execution_writer,
client_key_usage_writer,
admission_release_writer,
retention,
)?,
None,
),
@@ -273,6 +278,7 @@ async fn connect(
admin_ports,
core_ports,
provider_ports,
retention,
worker_leader_lease,
health_probes,
worker_contributions,
+1 -1
View File
@@ -12,7 +12,7 @@ use gateway_admin::model::settings::{
AdminApiKey, AdminApiKeyMutation, ModelMappings, ReplaceRuntimeSettings,
RotationStrategy as AdminRotationStrategy, RuntimeSettings as AdminRuntimeSettings,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision as AdminRevision};
use gateway_admin::model::{MutationContext, Revision as AdminRevision};
use gateway_admin::ports::backup::BackupStorePorts;
use gateway_admin::ports::store::{
AdminAccountStorePorts, AdminStoreError, AdminStoreErrorKind, AdminStorePorts,
@@ -1,5 +1,6 @@
//! PostgreSQL owner for provider-neutral account groups and memberships.
use gateway_admin::model::audit::MutationAuditOperation;
use std::{collections::BTreeMap, str::FromStr as _};
use async_trait::async_trait;
@@ -231,8 +232,7 @@ impl AccountGroupStore for PgAccountGroupRepository {
let id = command.id.clone();
let audit = mutation_audit(
context,
"create",
"account_group",
MutationAuditOperation::AccountGroupCreate,
id.as_str(),
vec![
"name".to_owned(),
@@ -264,8 +264,7 @@ impl AccountGroupStore for PgAccountGroupRepository {
let id = command.id.clone();
let audit = mutation_audit(
context,
"update",
"account_group",
MutationAuditOperation::AccountGroupUpdate,
id.as_str(),
vec![
"name".to_owned(),
@@ -308,8 +307,9 @@ impl AccountGroupStore for PgAccountGroupRepository {
let id = command.id.clone();
let audit = mutation_audit(
context,
if command.enabled { "enable" } else { "disable" },
"account_group",
MutationAuditOperation::AccountGroupEnabled {
enabled: command.enabled,
},
id.as_str(),
vec!["enabled".to_owned()],
);
@@ -341,7 +341,12 @@ impl AccountGroupStore for PgAccountGroupRepository {
context: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
let id = command.id.clone();
let audit = mutation_audit(context, "delete", "account_group", id.as_str(), Vec::new());
let audit = mutation_audit(
context,
MutationAuditOperation::AccountGroupDelete,
id.as_str(),
Vec::new(),
);
let revision = self
.mutate(audit, |transaction| {
Box::pin(async move {
@@ -28,6 +28,18 @@ impl AdminAuditActorKind {
}
}
impl From<gateway_admin::model::auth::AuditActorKind> for AdminAuditActorKind {
fn from(value: gateway_admin::model::auth::AuditActorKind) -> Self {
use gateway_admin::model::auth::AuditActorKind;
match value {
AuditActorKind::AdminSession => Self::AdminSession,
AuditActorKind::AdminApiKey => Self::AdminApiKey,
AuditActorKind::System => Self::System,
AuditActorKind::Anonymous => Self::Anonymous,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminAuditEvent {
pub id: String,
@@ -5,12 +5,14 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_admin::model::audit::MutationAuditOperation;
use secrecy::{ExposeSecret as _, SecretString};
use sqlx::{PgPool, Postgres, Row as _, Transaction};
use gateway_admin::model::backup::{
BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupRecordSeed, BackupSettings,
BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
BackupStatus, BackupStatusTransition, BackupTriggerKind, UpdateBackupScheduleCommand,
UpdateBackupStorageCommand,
};
use gateway_admin::model::{MutationContext, Revision as AdminRevision};
use gateway_admin::ports::backup::{BackupRepository, StatusTransitionUpdate};
@@ -118,8 +120,7 @@ impl BackupRepository for PgBackupRepository {
}
let audit = crate::mutation_audit(
context,
"backup.s3_config_updated",
"backup_settings",
MutationAuditOperation::BackupStorageUpdate,
"1",
changed,
);
@@ -184,8 +185,7 @@ impl BackupRepository for PgBackupRepository {
let audit = crate::mutation_audit(
context,
"backup.schedule_updated",
"backup_settings",
MutationAuditOperation::BackupScheduleUpdate,
"1",
vec![
"schedule_enabled".to_owned(),
@@ -460,8 +460,7 @@ impl BackupRepository for PgBackupRepository {
async fn transition_status(
&self,
id: &str,
from: BackupStatus,
to: BackupStatus,
transition: BackupStatusTransition,
update: StatusTransitionUpdate,
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>> {
@@ -487,7 +486,7 @@ impl BackupRepository for PgBackupRepository {
attempt_count, error_code, error_message, started_at, completed_at, expires_at,
created_at, updated_at",
)
.bind(to.as_str())
.bind(transition.to().as_str())
.bind(size_bytes)
.bind(update.sha256.as_deref())
.bind(update.error_code.as_deref())
@@ -495,7 +494,7 @@ impl BackupRepository for PgBackupRepository {
.bind(update.completed_at)
.bind(now)
.bind(id)
.bind(from.as_str())
.bind(transition.from().as_str())
.fetch_optional(&self.pool)
.await
.map_err(|_| store_unavailable("transition backup task"))?
@@ -1,5 +1,6 @@
//! 按 Key 串行检查限额,并幂等累计已取得的 USD 费用。
use gateway_admin::model::audit::MutationAuditOperation;
use std::{collections::BTreeMap, sync::Mutex, time::Duration};
use chrono::{DateTime, Utc};
@@ -104,8 +105,7 @@ async fn reset_client_key_budget_in_transaction(
tx,
mutation_audit(
context,
"reset_budget",
"client_api_key",
MutationAuditOperation::ClientApiKeyResetBudget,
command.id.as_str(),
fields,
),
@@ -1,5 +1,6 @@
//! 明文 `client_api_keys` 的 PostgreSQL owner。
use gateway_admin::model::audit::MutationAuditOperation;
use std::{
collections::{BTreeMap, BTreeSet},
fmt,
@@ -783,8 +784,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
&mut tx,
mutation_audit(
context,
"update_budget_limits",
"client_api_key",
MutationAuditOperation::ClientApiKeyUpdateBudgetLimits,
command.id.as_str(),
fields,
),
@@ -884,8 +884,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
},
mutation_audit(
context,
"create",
"client_api_key",
MutationAuditOperation::ClientApiKeyCreate,
id.as_str(),
[
"name",
@@ -935,8 +934,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
},
mutation_audit(
context,
"update",
"client_api_key",
MutationAuditOperation::ClientApiKeyUpdate,
id.as_str(),
[
"name",
@@ -971,8 +969,9 @@ impl ClientKeyStore for PgAdminClientKeyStore {
command.enabled,
mutation_audit(
context,
if command.enabled { "enable" } else { "disable" },
"client_api_key",
MutationAuditOperation::ClientApiKeyEnabled {
enabled: command.enabled,
},
id.as_str(),
vec!["enabled".to_owned()],
),
@@ -992,8 +991,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
command.id.as_str(),
mutation_audit(
context,
"delete",
"client_api_key",
MutationAuditOperation::ClientApiKeyDelete,
command.id.as_str(),
Vec::new(),
),
@@ -1,4 +1,5 @@
use async_trait::async_trait;
use gateway_admin::model::audit::MutationAuditOperation;
use gateway_admin::model::plugins::distribution::{SourceCredential, SourceCredentialInfo};
use gateway_admin::{
model::{
@@ -268,8 +269,7 @@ impl PluginStore for PgPluginStore {
&mut transaction,
mutation_audit(
context,
"install",
"plugin_artifact",
MutationAuditOperation::PluginArtifactInstall,
&metadata.plugin_id,
vec!["artifact".into(), "source".into()],
),
@@ -315,8 +315,7 @@ impl PluginStore for PgPluginStore {
&mut transaction,
mutation_audit(
context,
"accept",
"plugin_artifact",
MutationAuditOperation::PluginArtifactAccept,
digest,
vec!["acceptedAt".into()],
),
@@ -373,8 +372,7 @@ impl PluginStore for PgPluginStore {
&mut transaction,
mutation_audit(
context,
"delete",
"plugin_artifact",
MutationAuditOperation::PluginArtifactDelete,
&id,
vec!["artifact".into()],
),
@@ -1,3 +1,4 @@
use gateway_admin::model::audit::MutationAuditOperation;
use gateway_admin::{
model::{
MutationContext, Revision,
@@ -78,8 +79,7 @@ pub(super) async fn save(
&mut tx,
mutation_audit(
context,
"create",
"plugin_source_credential",
MutationAuditOperation::PluginSourceCredentialCreate,
&credential.info.id,
vec!["credential".into()],
),
@@ -112,8 +112,7 @@ pub(super) async fn delete_unused(
tx,
mutation_audit(
context,
"delete",
"plugin_source_credential",
MutationAuditOperation::PluginSourceCredentialDelete,
&id.to_string(),
vec!["credential".into()],
),
@@ -155,8 +154,7 @@ pub(super) async fn delete(
&mut tx,
mutation_audit(
context,
"delete",
"plugin_source_credential",
MutationAuditOperation::PluginSourceCredentialDelete,
id,
vec!["credential".into()],
),
@@ -1,3 +1,4 @@
use gateway_admin::model::audit::MutationAuditOperation;
use std::collections::{BTreeMap, BTreeSet};
use gateway_admin::{
@@ -308,8 +309,7 @@ async fn save_inner(
&mut tx,
mutation_audit(
context,
"configure",
"plugin_instance",
MutationAuditOperation::PluginInstanceConfigure,
&replacement.id,
vec!["enabled".into()],
),
@@ -333,8 +333,7 @@ async fn save_inner(
&mut tx,
mutation_audit(
context,
"configure",
"plugin_instance",
MutationAuditOperation::PluginInstanceConfigure,
&instance.id,
vec![
"artifact".into(),
@@ -385,8 +384,7 @@ pub(super) async fn delete(
&mut tx,
mutation_audit(
context,
"delete",
"plugin_instance",
MutationAuditOperation::PluginInstanceDelete,
id,
vec!["instance".into()],
),
@@ -8,6 +8,7 @@ use super::super::{
use super::PgPluginStore;
use crate::{admin_revision, admin_store_error, mutation_audit};
use async_trait::async_trait;
use gateway_admin::model::audit::MutationAuditOperation;
use gateway_admin::{
model::{
MutationContext,
@@ -65,7 +66,7 @@ fn validate_resource_key(key: &str) -> AdminStoreResult<()> {
async fn finish<T>(
mut tx: Transaction<'_, Postgres>,
context: &MutationContext,
kind: &str,
kind: MutationAuditOperation,
id: &str,
changed: bool,
value: T,
@@ -74,13 +75,7 @@ async fn finish<T>(
let revision = bump_config_revision_in_transaction(&mut tx)
.await
.map_err(|e| admin_store_error("plugin resource", e))?;
let audit = mutation_audit(
context,
"plugin_reconcile",
kind,
id,
vec!["plugin_owned_resource".to_owned()],
);
let audit = mutation_audit(context, kind, id, vec!["plugin_owned_resource".to_owned()]);
append_admin_audit_event_in_transaction(&mut tx, audit, revision)
.await
.map_err(|e| admin_store_error("plugin resource", e))?;
@@ -123,7 +118,15 @@ impl PluginResourceStore for PgPluginStore {
.await
.map_err(|_| unavailable())?
{
return finish(tx, context, "account_group", "", false, resource(&row)?).await;
return finish(
tx,
context,
MutationAuditOperation::AccountGroupPluginReconcile,
"",
false,
resource(&row)?,
)
.await;
}
insert_account_group_in_transaction(&mut tx, &command)
.await
@@ -142,7 +145,7 @@ impl PluginResourceStore for PgPluginStore {
finish(
tx,
context,
"account_group",
MutationAuditOperation::AccountGroupPluginReconcile,
command.id.as_str(),
true,
value,
@@ -181,7 +184,15 @@ impl PluginResourceStore for PgPluginStore {
.await
.map_err(|_| unavailable())?
{
return finish(tx, context, "client_api_key", "", false, resource(&row)?).await;
return finish(
tx,
context,
MutationAuditOperation::ClientApiKeyPluginReconcile,
"",
false,
resource(&row)?,
)
.await;
}
let group_ids: Vec<String> = sqlx::query_scalar("select group_id from plugin_group_resources where instance_id=$1 and resource_key=any($2::text[])")
.bind(instance)
@@ -219,7 +230,15 @@ impl PluginResourceStore for PgPluginStore {
name: key.name.trim().to_owned(),
enabled: true,
};
finish(tx, context, "client_api_key", &key.id, true, value).await
finish(
tx,
context,
MutationAuditOperation::ClientApiKeyPluginReconcile,
&key.id,
true,
value,
)
.await
}
async fn change_members(
@@ -274,7 +293,7 @@ impl PluginResourceStore for PgPluginStore {
finish(
tx,
context,
"account_group",
MutationAuditOperation::AccountGroupPluginReconcile,
&group,
added + removed > 0,
GroupMembersChanged { added, removed },
@@ -1,3 +1,4 @@
use gateway_admin::model::audit::MutationAuditOperation;
use gateway_admin::{
model::{
MutationContext, Revision,
@@ -69,8 +70,7 @@ pub(super) async fn change(
&mut tx,
mutation_audit(
context,
"change_source",
"plugin_source",
MutationAuditOperation::PluginSourceChangeSource,
&binding.plugin_id,
vec!["source".into(), "policy".into(), "outbound_proxy".into()],
),
@@ -128,8 +128,7 @@ pub(super) async fn delete_if_unused(
tx,
mutation_audit(
context,
"delete",
"plugin_source",
MutationAuditOperation::PluginSourceDelete,
plugin_id,
vec!["source".into(), "policy".into(), "outbound_proxy".into()],
),
@@ -1,5 +1,6 @@
//! `gateway-admin` 账号端口的 PostgreSQL adapter。
use gateway_admin::model::audit::MutationAuditOperation;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -229,7 +230,7 @@ impl PgAdminAccountStore {
prepared: PreparedCredentialImport,
settings: Option<AccountImportSettings>,
context: &MutationContext,
action: &str,
action: MutationAuditOperation,
outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
) -> AdminStoreResult<CredentialImportResult> {
let command = prepare_import(prepared, settings, context, action, outbound_proxy)?;
@@ -246,7 +247,7 @@ impl PgAdminAccountStore {
prepared: PreparedCredentialRotationFacts,
settings: Option<UpdateAccount>,
context: &MutationContext,
action: &str,
action: MutationAuditOperation,
) -> AdminStoreResult<CredentialMutationResult> {
let account_id = prepared.account_id.clone();
let command = prepare_rotation(prepared, settings, context, action)?;
@@ -588,7 +589,7 @@ impl AccountStore for PgAdminAccountStore {
command.prepared,
command.settings,
context,
"import_document",
MutationAuditOperation::ProviderAccountImportDocument,
command.outbound_proxy,
)
.await?;
@@ -620,7 +621,7 @@ impl AccountStore for PgAdminAccountStore {
command.prepared,
command.settings,
context,
"rotate_credential",
MutationAuditOperation::ProviderAccountRotateCredential,
)
.await
}
@@ -637,8 +638,13 @@ impl AccountStore for PgAdminAccountStore {
"credential refresh cannot change account settings",
));
}
self.commit_prepared_rotation(command.prepared, None, context, "refresh_credential")
.await
self.commit_prepared_rotation(
command.prepared,
None,
context,
MutationAuditOperation::ProviderAccountRefreshCredential,
)
.await
}
async fn update_account(
@@ -681,8 +687,7 @@ impl AccountStore for PgAdminAccountStore {
outbound_proxy: command.outbound_proxy,
audit: mutation_audit(
context,
"update",
"provider_account",
MutationAuditOperation::ProviderAccountUpdate,
&command.account_id,
changed_fields,
),
@@ -730,13 +735,7 @@ impl AccountStore for PgAdminAccountStore {
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
append_admin_audit_event_in_transaction(
&mut transaction,
mutation_audit(
context,
"adapt_concurrency",
"provider_account",
account_id.as_str(),
vec!["concurrency_limit".to_owned()],
),
mutation_audit(context, MutationAuditOperation::ProviderAccountAdaptConcurrency, account_id.as_str(), vec!["concurrency_limit".to_owned()]),
revision,
)
.await?;
@@ -780,8 +779,7 @@ impl AccountStore for PgAdminAccountStore {
account_id: account_id.as_str().to_owned(),
audit: mutation_audit(
context,
"recover",
"provider_account",
MutationAuditOperation::ProviderAccountRecover,
account_id.as_str(),
vec!["status".to_owned(), "quota".to_owned()],
),
@@ -848,8 +846,7 @@ impl AccountStore for PgAdminAccountStore {
outbound_proxy: command.outbound_proxy,
audit: mutation_audit(
context,
"batch_update",
"provider_account",
MutationAuditOperation::ProviderAccountBatchUpdate,
&audit_target,
changed_fields,
),
@@ -887,8 +884,7 @@ impl AccountStore for PgAdminAccountStore {
account_ids: command.account_ids,
audit: mutation_audit(
context,
"delete",
"provider_account",
MutationAuditOperation::ProviderAccountDelete,
&audit_target,
Vec::new(),
),
@@ -941,8 +937,7 @@ impl AccountStore for PgAdminAccountStore {
&mut transaction,
mutation_audit(
context,
"export_credentials",
"provider_account",
MutationAuditOperation::ProviderAccountExportCredentials,
account_id,
Vec::new(),
),
@@ -963,7 +958,7 @@ pub(super) fn prepare_import(
prepared: PreparedCredentialImport,
settings: Option<AccountImportSettings>,
context: &MutationContext,
action: &str,
action: MutationAuditOperation,
outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
) -> AdminStoreResult<ImportProviderAccounts> {
let provider_kind = prepared.provider_kind.as_str().to_owned();
@@ -997,13 +992,7 @@ pub(super) fn prepare_import(
provider_kind: provider_kind.clone(),
},
accounts,
audit: mutation_audit(
context,
action,
"provider_account",
&provider_kind,
changed_fields,
),
audit: mutation_audit(context, action, &provider_kind, changed_fields),
})
}
@@ -1062,7 +1051,7 @@ pub(super) fn prepare_rotation(
prepared: PreparedCredentialRotationFacts,
settings: Option<UpdateAccount>,
context: &MutationContext,
action: &str,
action: MutationAuditOperation,
) -> AdminStoreResult<RotateProviderAccount> {
let account_id = prepared.account_id.clone();
let scope = ProviderAccountAdminScope {
@@ -1103,13 +1092,7 @@ pub(super) fn prepare_rotation(
preserve_profile: prepared.preserve_profile,
preserve_credential_state: prepared.preserve_credential_state,
},
audit: mutation_audit(
context,
action,
"provider_account",
account_id.as_str(),
changed_fields,
),
audit: mutation_audit(context, action, account_id.as_str(), changed_fields),
})
}
@@ -93,7 +93,7 @@ async fn commit_authorization_in_transaction(
},
command.settings,
context,
"authorize",
gateway_admin::model::audit::MutationAuditOperation::ProviderAccountAuthorize,
command
.pending
.outbound_proxy_id()
@@ -120,8 +120,12 @@ async fn commit_authorization_in_transaction(
{
return Err(invalid_receipt());
}
let prepared =
admin_adapter::prepare_rotation(*prepared, None, context, "reauthorize")?;
let prepared = admin_adapter::prepare_rotation(
*prepared,
None,
context,
gateway_admin::model::audit::MutationAuditOperation::ProviderAccountReauthorize,
)?;
let result =
repository::rotate_provider_account_admin_in_transaction(transaction, prepared)
.await
@@ -1,5 +1,6 @@
//! 命名代理持有认证信息,账号保留解析后的 URL 供 Provider 传输使用。
use gateway_admin::model::audit::MutationAuditOperation;
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
@@ -323,7 +324,7 @@ pub(crate) async fn resolve_proxy_selection(
async fn audit(
transaction: &mut Transaction<'_, Postgres>,
context: &MutationContext,
action: &str,
action: MutationAuditOperation,
id: &str,
fields: &[&str],
revision: Revision,
@@ -333,7 +334,6 @@ async fn audit(
mutation_audit(
context,
action,
"outbound_proxy",
id,
fields.iter().map(|value| (*value).to_owned()).collect(),
),
@@ -377,8 +377,7 @@ impl ProxyStore for PgProxyRepository {
&mut transaction,
mutation_audit(
context,
"update",
"provider_account",
MutationAuditOperation::ProviderAccountUpdate,
account_id.as_str(),
vec!["outbound_proxy".to_owned()],
),
@@ -634,7 +633,7 @@ impl ProxyStore for PgProxyRepository {
audit(
&mut transaction,
context,
"create",
MutationAuditOperation::OutboundProxyCreate,
&id,
&["name", "proxy_url", "location", "auto_location"],
revision,
@@ -720,7 +719,7 @@ impl ProxyStore for PgProxyRepository {
audit(
&mut transaction,
context,
"update",
MutationAuditOperation::OutboundProxyUpdate,
&command.id,
&["name", "proxy_url", "location", "auto_location"],
revision,
@@ -775,7 +774,7 @@ impl ProxyStore for PgProxyRepository {
audit(
&mut transaction,
context,
"delete",
MutationAuditOperation::OutboundProxyDelete,
id,
&[],
config_revision,
@@ -843,7 +842,7 @@ impl ProxyStore for PgProxyRepository {
audit(
&mut transaction,
context,
"test",
MutationAuditOperation::OutboundProxyTest,
id,
&["last_test", "detected_location"],
revision,
@@ -1,250 +1,106 @@
//! 终态请求、后台事件和审计事件的保留期清理。
//! PostgreSQL 保留期投影与单批删除适配器。
use crate::{admin_store_error, postgres_unavailable};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_admin::{
model::retention::{RetentionPolicy, RetentionTarget},
ports::{
retention::RetentionStore,
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use sqlx::PgPool;
use std::time::{Duration, Instant};
use crate::{StoreError, StoreResult, postgres_unavailable};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RuntimeRetentionSettings {
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RetentionReport {
pub model_requests: u64,
pub ops_events: u64,
pub admin_audit_events: u64,
pub batches: u32,
pub budget_exhausted: bool,
}
/// 一次 retention cycle 可占用的删除预算;不从部署配置暴露低价值开关。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionCycleBudget {
batch_rows: u32,
max_batches: u32,
max_duration: Duration,
batch_pause: Duration,
}
impl RetentionCycleBudget {
pub fn try_new(
batch_rows: u32,
max_batches: u32,
max_duration: Duration,
batch_pause: Duration,
) -> StoreResult<Self> {
if batch_rows == 0 || max_batches == 0 || max_duration.is_zero() {
return Err(StoreError::InvalidData {
entity: "retention cycle budget",
message: "row, batch, and duration budgets must be positive".to_owned(),
});
}
Ok(Self {
batch_rows,
max_batches,
max_duration,
batch_pause,
})
}
const fn production() -> Self {
Self {
batch_rows: 5_000,
max_batches: 12,
max_duration: Duration::from_secs(30),
batch_pause: Duration::from_millis(50),
}
}
}
#[async_trait]
pub trait RetentionRepository: Send + Sync {
async fn load_retention_settings(&self) -> StoreResult<RuntimeRetentionSettings>;
async fn apply_retention(
&self,
now: DateTime<Utc>,
settings: RuntimeRetentionSettings,
) -> StoreResult<RetentionReport>;
}
use std::num::NonZeroU32;
#[derive(Clone)]
pub struct PgRetentionRepository {
pool: PgPool,
cycle_budget: RetentionCycleBudget,
}
impl PgRetentionRepository {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self {
pool,
cycle_budget: RetentionCycleBudget::production(),
}
}
#[must_use]
pub const fn with_cycle_budget(pool: PgPool, cycle_budget: RetentionCycleBudget) -> Self {
Self { pool, cycle_budget }
Self { pool }
}
}
#[async_trait]
impl RetentionRepository for PgRetentionRepository {
async fn load_retention_settings(&self) -> StoreResult<RuntimeRetentionSettings> {
impl RetentionStore for PgRetentionRepository {
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy> {
let row = sqlx::query_as::<_, (i64, i64, i64)>(
"select usage_retention_days, ops_event_retention_days, audit_retention_days
from runtime_settings where id = 1",
)
.fetch_optional(&self.pool)
.await
.map_err(|_| postgres_unavailable("load retention settings"))?
.ok_or_else(|| StoreError::NotFound {
entity: "runtime settings",
id: "1".to_owned(),
.map_err(|_| unavailable("load retention settings"))?
.ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::NotFound,
"retention settings",
"runtime settings not found",
)
})?;
Ok(RuntimeRetentionSettings {
usage_retention_days: to_u32(row.0)?,
ops_event_retention_days: to_u32(row.1)?,
audit_retention_days: to_u32(row.2)?,
})
let to_days = |value| u32::try_from(value).map_err(|_| invalid_policy());
RetentionPolicy::try_new(to_days(row.0)?, to_days(row.1)?, to_days(row.2)?)
.map_err(|_| invalid_policy())
}
async fn apply_retention(
async fn purge_batch(
&self,
target: RetentionTarget,
now: DateTime<Utc>,
settings: RuntimeRetentionSettings,
) -> StoreResult<RetentionReport> {
if settings.usage_retention_days < 31
|| settings.ops_event_retention_days == 0
|| settings.audit_retention_days == 0
{
return Err(StoreError::InvalidData {
entity: "retention settings",
message: "retention values violate the frozen constraints".to_owned(),
});
}
// 各表轮转执行单批独立事务;行数、批数和 wall-clock 同时有界。
let mut targets = [
RetentionTarget::new(
policy: RetentionPolicy,
limit: NonZeroU32,
) -> AdminStoreResult<u64> {
let sql = match target {
RetentionTarget::ModelRequests => {
"delete from model_requests
where ctid in (
select ctid from model_requests
where outcome <> 'running'
and completed_at < $1 - ($2 * interval '1 day')
limit $3
)",
settings.usage_retention_days,
"delete expired model requests",
),
RetentionTarget::new(
)"
}
RetentionTarget::OpsEvents => {
"delete from ops_events
where ctid in (
select ctid from ops_events
where model_request_id is null
and created_at < $1 - ($2 * interval '1 day')
limit $3
)",
settings.ops_event_retention_days,
"delete expired ops events",
),
RetentionTarget::new(
)"
}
RetentionTarget::AdminAuditEvents => {
"delete from admin_audit_events
where ctid in (
select ctid from admin_audit_events
where created_at < $1 - ($2 * interval '1 day')
limit $3
)",
settings.audit_retention_days,
"delete expired admin audit events",
),
];
let started_at = Instant::now();
let mut batches = 0_u32;
'cycles: loop {
let mut attempted = false;
for target in &mut targets {
if target.complete {
continue;
}
if batches >= self.cycle_budget.max_batches
|| started_at.elapsed() >= self.cycle_budget.max_duration
{
break 'cycles;
}
attempted = true;
let deleted =
purge_batch(&self.pool, target, now, self.cycle_budget.batch_rows).await?;
batches = batches.saturating_add(1);
target.deleted = target.deleted.saturating_add(deleted);
target.complete = deleted < u64::from(self.cycle_budget.batch_rows);
if !target.complete
&& batches < self.cycle_budget.max_batches
&& started_at.elapsed() < self.cycle_budget.max_duration
&& !self.cycle_budget.batch_pause.is_zero()
{
tokio::time::sleep(self.cycle_budget.batch_pause).await;
}
)"
}
if !attempted || targets.iter().all(|target| target.complete) {
break;
}
}
Ok(RetentionReport {
model_requests: targets[0].deleted,
ops_events: targets[1].deleted,
admin_audit_events: targets[2].deleted,
batches,
budget_exhausted: targets.iter().any(|target| !target.complete),
})
};
sqlx::query(sql)
.bind(now)
.bind(i64::from(policy.days(target)))
.bind(i64::from(limit.get()))
.execute(&self.pool)
.await
.map(|result| result.rows_affected())
.map_err(|_| unavailable("delete expired records"))
}
}
struct RetentionTarget {
delete_sql: &'static str,
retention_days: u32,
label: &'static str,
deleted: u64,
complete: bool,
fn unavailable(operation: &'static str) -> AdminStoreError {
admin_store_error("retention", postgres_unavailable(operation))
}
impl RetentionTarget {
const fn new(delete_sql: &'static str, retention_days: u32, label: &'static str) -> Self {
Self {
delete_sql,
retention_days,
label,
deleted: 0,
complete: false,
}
}
}
async fn purge_batch(
pool: &PgPool,
target: &RetentionTarget,
now: DateTime<Utc>,
batch_rows: u32,
) -> StoreResult<u64> {
sqlx::query(target.delete_sql)
.bind(now)
.bind(i64::from(target.retention_days))
.bind(i64::from(batch_rows))
.execute(pool)
.await
.map_err(|_| postgres_unavailable(target.label))
.map(|result| result.rows_affected())
}
fn to_u32(value: i64) -> StoreResult<u32> {
u32::try_from(value).map_err(|_| StoreError::InvalidData {
entity: "retention settings",
message: "retention days are outside the supported range".to_owned(),
})
fn invalid_policy() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
"retention settings",
"invalid retention policy",
)
}
+16 -29
View File
@@ -62,40 +62,27 @@ pub(crate) fn admin_revision(revision: Revision) -> AdminStoreResult<AdminRevisi
pub(crate) fn mutation_audit(
context: &MutationContext,
action: &str,
entity_kind: &str,
operation: gateway_admin::model::audit::MutationAuditOperation,
entity_ref: &str,
changed_fields: Vec<String>,
) -> postgres::AdminAuditEvent {
let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor {
MutationActor::AdminSession { admin_user_id } => (
postgres::AdminAuditActorKind::AdminSession,
Some(admin_user_id.clone()),
gateway_admin::model::auth::admin_session_actor_ref(admin_user_id),
),
MutationActor::AdminApiKey => (
postgres::AdminAuditActorKind::AdminApiKey,
None,
"admin_api_key".to_owned(),
),
MutationActor::System => (
postgres::AdminAuditActorKind::System,
None,
"system".to_owned(),
),
};
let event = gateway_admin::model::audit::MutationAuditIntent {
operation,
entity_ref,
}
.event(context, changed_fields);
postgres::AdminAuditEvent {
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
actor_kind,
actor_admin_user_id,
actor_ref,
admin_request_id: Some(context.request_id.clone()),
action: action.to_owned(),
entity_kind: entity_kind.to_owned(),
entity_ref: entity_ref.to_owned(),
id: event.id,
actor_kind: event.actor_kind.into(),
actor_admin_user_id: event.actor_admin_user_id,
actor_ref: event.actor_ref,
admin_request_id: event.request_id,
action: event.action,
entity_kind: event.entity_kind,
entity_ref: event.entity_ref,
config_revision: None,
changed_fields,
created_at: chrono::Utc::now(),
changed_fields: event.changed_fields,
created_at: event.occurred_at,
}
}
@@ -99,12 +99,9 @@ pub(crate) fn store_worker_contributions(
execution_writer: postgres::ExecutionObservationWriter<postgres::PgExecutionStore>,
client_key_usage_writer: postgres::PgClientApiKeyUsageWriter,
admission_release_writer: redis::ClientAdmissionReleaseWriter,
retention: Arc<postgres::PgRetentionRepository>,
) -> StoreResult<Vec<WorkerContribution>> {
let stale_id = WorkerId::try_new(WorkerKind::StaleModelRequestRecovery, "postgres")
.map_err(worker_definition_error)?;
let retention_id =
WorkerId::try_new(WorkerKind::Retention, "postgres").map_err(worker_definition_error)?;
let ops_flush_id =
WorkerId::try_new(WorkerKind::OpsFlush, "postgres").map_err(worker_definition_error)?;
let client_key_usage_flush_id =
@@ -121,11 +118,6 @@ pub(crate) fn store_worker_contributions(
Duration::from_secs(30),
Box::new(StaleModelRequestRecoveryTask { execution }),
)?),
WorkerContribution::Registration(scheduled_worker(
retention_id,
Duration::from_secs(60 * 60),
Box::new(RetentionTask { retention }),
)?),
WorkerContribution::Registration(
WorkerRegistration::try_new(
ops_flush_id,
@@ -215,46 +207,6 @@ impl ScheduledTask for StaleModelRequestRecoveryTask {
}
}
pub(crate) struct RetentionTask {
retention: Arc<postgres::PgRetentionRepository>,
}
impl ScheduledTask for RetentionTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let settings =
postgres::RetentionRepository::load_retention_settings(self.retention.as_ref())
.await
.map_err(|_| WorkerTaskError::safe("retention settings read failed"))?;
let started_at = std::time::Instant::now();
let cleanup = postgres::RetentionRepository::apply_retention(
self.retention.as_ref(),
chrono::Utc::now(),
settings,
);
let report = tokio::select! {
() = context.cancellation().cancelled() => return Ok(()),
result = cleanup => result
.map_err(|_| WorkerTaskError::safe("retention cleanup failed"))?,
};
tracing::info!(
model_requests = report.model_requests,
ops_events = report.ops_events,
admin_audit_events = report.admin_audit_events,
batches = report.batches,
budget_exhausted = report.budget_exhausted,
elapsed_milliseconds =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
"PostgreSQL retention cycle completed"
);
Ok(())
})
}
}
pub struct PostgresHealthProbe {
pool: sqlx::PgPool,
max_connections: u32,
@@ -4,8 +4,8 @@ use chrono::Utc;
use secrecy::SecretString;
use gateway_admin::model::backup::{
BackupRecordSeed, BackupSettings, BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand,
UpdateBackupStorageCommand,
BackupRecordSeed, BackupSettings, BackupStatus, BackupStatusTransition, BackupTriggerKind,
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
};
use gateway_admin::model::{MutationActor, MutationContext};
use gateway_admin::ports::backup::{BackupRepository, StatusTransitionUpdate};
@@ -36,6 +36,10 @@ fn context() -> MutationContext {
}
}
fn transition(from: BackupStatus, to: BackupStatus) -> BackupStatusTransition {
BackupStatusTransition::try_new(from, to).expect("legal backup status transition")
}
fn storage_command(endpoint: &str) -> UpdateBackupStorageCommand {
UpdateBackupStorageCommand {
endpoint: endpoint.to_owned(),
@@ -187,8 +191,7 @@ async fn backup_lifecycle_transitions_through_deleting_and_hard_delete() {
let uploading = repository
.transition_status(
&record.id,
BackupStatus::Dumping,
BackupStatus::Uploading,
transition(BackupStatus::Dumping, BackupStatus::Uploading),
update,
Utc::now(),
)
@@ -201,8 +204,7 @@ async fn backup_lifecycle_transitions_through_deleting_and_hard_delete() {
let completed = repository
.transition_status(
&record.id,
BackupStatus::Uploading,
BackupStatus::Completed,
transition(BackupStatus::Uploading, BackupStatus::Completed),
StatusTransitionUpdate::default(),
Utc::now(),
)
@@ -260,8 +262,7 @@ async fn active_task_unique_index_blocks_second_queued() {
repository
.transition_status(
&claimed.id,
BackupStatus::Dumping,
BackupStatus::Failed,
transition(BackupStatus::Dumping, BackupStatus::Failed),
StatusTransitionUpdate {
error_code: Some("backup.pg_dump_failed".to_owned()),
error_message: Some("boom".to_owned()),
@@ -1,24 +1,16 @@
use std::time::Duration;
use std::num::NonZeroU32;
use chrono::{Duration as ChronoDuration, Utc};
use gateway_store::postgres::{
PgRetentionRepository, RetentionCycleBudget, RetentionRepository as _, RuntimeRetentionSettings,
use gateway_admin::{
model::retention::{RetentionPolicy, RetentionTarget},
ports::retention::RetentionStore as _,
};
use gateway_store::postgres::PgRetentionRepository;
use super::TestDatabase;
#[test]
fn retention_settings_preserve_independent_windows() {
let settings = RuntimeRetentionSettings {
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
};
assert_eq!(settings.audit_retention_days, 90);
}
#[tokio::test]
async fn retention_cycle_should_stop_at_the_batch_budget() {
async fn retention_batch_deletes_only_expired_rows_up_to_limit() {
let Some(database) = TestDatabase::create("retention_cycle_budget").await else {
return;
};
@@ -29,39 +21,50 @@ async fn retention_cycle_should_stop_at_the_batch_budget() {
changed_fields, created_at
)
select 'retention-' || value::text, 'system', 'retention', 'cleanup',
'fixture', 'fixture-' || value::text, array[]::text[], $1
from generate_series(1, 5) as value",
'fixture', 'fixture-' || value::text, array[]::text[],
case when value = 6 then now() else $1 end
from generate_series(1, 6) as value",
)
.bind(expired_at)
.execute(&database.pool)
.await
.expect("seed expired audit events");
.expect("seed expired and retained audit events");
let budget = RetentionCycleBudget::try_new(2, 3, Duration::from_secs(1), Duration::ZERO)
.expect("retention cycle budget");
let repository = PgRetentionRepository::with_cycle_budget(database.pool.clone(), budget);
let report = repository
.apply_retention(
let repository = PgRetentionRepository::new(database.pool.clone());
let deleted = repository
.purge_batch(
RetentionTarget::AdminAuditEvents,
Utc::now(),
RuntimeRetentionSettings {
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
},
RetentionPolicy::try_new(31, 30, 90).unwrap(),
NonZeroU32::new(2).unwrap(),
)
.await
.expect("bounded retention cycle");
assert_eq!(report.model_requests, 0);
assert_eq!(report.ops_events, 0);
assert_eq!(report.admin_audit_events, 2);
assert_eq!(report.batches, 3);
assert!(report.budget_exhausted);
.expect("bounded retention batch");
assert_eq!(deleted, 2);
let remaining: i64 =
sqlx::query_scalar("select count(*) from admin_audit_events where id like 'retention-%'")
.fetch_one(&database.pool)
.await
.expect("count remaining audit events");
assert_eq!(remaining, 3);
assert_eq!(remaining, 4);
let policy = repository.load_policy().await.expect("load valid policy");
assert_eq!(
repository
.purge_batch(
RetentionTarget::AdminAuditEvents,
Utc::now(),
policy,
NonZeroU32::new(10).unwrap(),
)
.await
.expect("finish expired audit cleanup"),
3,
);
let retained: Vec<String> =
sqlx::query_scalar("select id from admin_audit_events where id like 'retention-%'")
.fetch_all(&database.pool)
.await
.expect("read retained audit events");
assert_eq!(retained, ["retention-6"]);
database.close().await;
}
@@ -40,11 +40,7 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() {
.collect::<BTreeSet<_>>();
assert_eq!(
kinds,
BTreeSet::from([
WorkerKind::StaleModelRequestRecovery,
WorkerKind::Retention,
WorkerKind::OpsFlush,
])
BTreeSet::from([WorkerKind::StaleModelRequestRecovery, WorkerKind::OpsFlush,])
);
assert!(first.take_worker_contributions().is_empty());
@@ -257,7 +257,6 @@ async fn native_response_translation_keeps_raw_xai_and_delivery_state_independen
)
.await
.expect("native response stream");
assert!(stream.has_native_response_translator());
let mut added_was_deferred = false;
let mut done_expanded = false;
@@ -332,7 +331,6 @@ async fn pass_through_middleware_projects_native_response_once() {
)
.await
.expect("native response stream");
assert!(!stream.has_native_response_translator());
let mut client_events = 0;
while let Some(event) = stream.next().await {
@@ -3915,7 +3913,6 @@ async fn cancellation_before_poll_never_calls_transport() {
)
.await
.expect("prepared stream");
assert!(!stream.has_native_response_translator());
cancellation.cancel();
let error = stream
.next()
+11 -4
View File
@@ -59,10 +59,10 @@ flowchart TB
| `backend/apps/plugin-cli`(包名 `codex-proxy-plugin-cli`) | `cpr-plugin package` 校验作者清单并生成平台元数据、资源摘要与归档;内部仅依赖 SDK,不参与网关运行时加载 |
| `gateway-protocol` | 跨层共享的 OpenAI wire contract、SSE 编解码与无业务 owner 的解析事实,不依赖其他 workspace crate |
| `gateway-core` | operation、canonical event、请求快照、路由、admission、attempt 协调、交付边界和计量 |
| `gateway-admin` | 管理领域、Key 用量查询、Provider/Store 端口、审计语义和备份策略 |
| `gateway-admin` | 管理领域、Key 用量查询、Provider/Store 端口、审计语义、备份与历史保留策略 |
| `gateway-api` | HTTP/WS/SSE 解码与交付、Admin 与 Key 用量 wire、静态 Web UI;不直接访问 Store 或具体 Provider |
| `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 |
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督、系统更新及外部价格源适配 |
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督与历史保留任务、系统更新及外部价格源适配 |
| `gateway-plugin/sdk`(包名 `gateway-plugin-sdk`) | 公开插件清单与线协议,独立于网关领域;异步收发通过可选 `io` feature 提供 |
| `gateway-plugin/runtime`(包名 `gateway-plugin-runtime`) | 插件包校验、能力适配、双向 RPC 与发布集合;通过 Host 管理子进程和受管 HTTP |
| `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images/Search transport |
@@ -78,6 +78,10 @@ flowchart TB
3. API 只调用 Core/Admin 抽象;Store 只实现端口
4. 具体实现只在组合根相遇
管理变更的审计动作、实体分类与 actor 投影由 Admin 的类型化操作和审计意图定义。
Store 按端口命令与实际提交结果选择操作、补齐稳定实体标识和物理差异,在同一事务写入配置 revision 与审计记录;
适配器不另定义动作和实体分类字符串,也不为生成审计而在事务外重复读取旧值
`frontend/` 是独立的 Node 项目,自行管理依赖、pnpm 配置、锁文件和 ESLint;仓库根目录不建立前端 workspace。
管理端与独立示例仓库分别依赖 `@codex-proxy/ui` 的固定 GitHub 标签或提交,通过锁文件固定实际提交并校验源码归档完整性,不要求同级源码目录。安装与构建许可见 [开发环境](development.md#环境与依赖)。
`modules/ui` 与 `modules/plugins` 是可选的 Git 子模块,分别指向两个独立仓库;不加入宿主的 Cargo 或 pnpm workspace,不参与宿主发行构建。源码联调和版本指针维护见 [开发指南](development.md)。
@@ -122,7 +126,7 @@ flowchart LR
公开列表、详情、原生目录和受管模型查询复用相同别名与账号访问范围,不另维护插件专用目录
- **重试由 Core 裁决**:`retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。
发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核
- **转换声明不替代正文事实**:middleware v3 在 request 阶段声明具体承担的功能和额外上游需求,原始需求保留用于诊断,实际正文与未承担的原始功能仍参与选路。
- **转换声明不替代正文事实**:middleware v3 在 request 阶段声明具体承担的功能和额外上游需求,逐层继承尚未承担的需求,实际正文与有效需求共同参与选路。
attempt 阶段拒绝需求声明。没有改写的正文、header 和响应帧在相同网关边界保留原字节与顺序
- **安装意味着完整信任**:只读校验不持久化;上传、URL 和 GitHub 的确认安装记录对精确摘要的信任,
@@ -132,6 +136,8 @@ flowchart LR
读取和重复安装不重写已有 JSON,废弃字段通过迁移清理;新增必填字段或改变语义时须提供迁移。包清单、RPC 与能力合同仍按 SDK 校验,运行兼容性由宿主版本范围及清单、协议、能力版本决定
- **一份发布快照**:数据面和管理页面使用同一不可变扩展集合;CLI 复用准备与调用合同,按次读取持久化配置。
新配置准备成功后才提交发布,在途请求持有旧集合直到结束;管理目标和回调另按当前身份与版本复验
- **发布能力显式实现**:Admin 分别定义准备、运行诊断和状态生命周期端口,完整发布实现必须满足全部合同。
诊断可以明确返回不可用;状态激活、实例排空与迁移必须由实现显式处理,不以缺省空操作或静默成功代替
- **插件故障按实例隔离**:恢复时将启动失败的实例及其错误保留在发布集合中,正常实例和原生能力继续发布;
用户正在修改的实例仍须准备成功才能提交。集合区分进程就绪、可继续服务与需要后台重建,单个进程退出不撤销全部请求快照。
故障绑定保留原有作用范围和拒绝/委托策略,入口认证故障不降级为其他认证方式;宿主配置存储与 revision 无法确认时仍停止新请求
@@ -733,7 +739,8 @@ Provider metadata 的 `upstreamServiceTier`,不改写客户端收到的响应
Worker 由各 Bundle 贡献、由 Host 统一监督:
- Store:过期请求恢复、历史保留和 PostgreSQL/Redis 观测队列
- Store:过期请求恢复和 PostgreSQL/Redis 观测队列
- Host:历史保留任务,持有每小时调度、单轮行数/批数/时长预算、取消和日志;Admin 校验保留窗口,Store 执行有界批量删除
- Core:`runtime` owner 的 RuntimeSnapshot 周期对账和 Redis change 订阅
- Admin:S3/R2 备份 daemon,负责调度、执行、删除收敛与保留清理;
以及账号冻结恢复 worker(容量熔断的自适应并发下调与到期探测解冻)
+45
View File
@@ -0,0 +1,45 @@
import type { Plugin } from 'vite'
import { fileURLToPath } from 'node:url'
import { build } from 'vite'
// 桥运行在隔离 iframe 中,独立编译以保证注入源码不依赖宿主闭包或分块。
export function pluginBridge(): Plugin {
const id = 'virtual:plugin-bridge-source'
const resolvedId = `\0${id}`
const entry = fileURLToPath(new URL('../src/views/plugins/management/bridge-runtime.ts', import.meta.url))
const dependencies = new Set<string>()
return {
name: 'plugin-management-bridge',
resolveId: source => source === id ? resolvedId : undefined,
async load(source) {
if (source !== resolvedId)
return
const result = await build({
configFile: false,
logLevel: 'silent',
build: {
write: false,
minify: true,
lib: { entry, name: 'CodexProxyPluginBridge', formats: ['iife'] },
},
})
const outputs = (Array.isArray(result) ? result : [result])
.flatMap(output => 'output' in output ? output.output : [])
const [chunk] = outputs
if (outputs.length !== 1 || chunk?.type !== 'chunk' || chunk.imports.length || chunk.dynamicImports.length)
throw new Error('插件管理桥必须构建为单个独立脚本')
dependencies.clear()
for (const dependency of Object.keys(chunk.modules)) {
dependencies.add(dependency)
this.addWatchFile(dependency)
}
return `export default ${JSON.stringify(chunk.code)}`
},
handleHotUpdate({ file, server }) {
if (dependencies.has(file)) {
const module = server.moduleGraph.getModuleById(resolvedId)
return module ? [module] : []
}
},
}
}
+5
View File
@@ -2,3 +2,8 @@
declare module '@fontsource-variable/inter'
declare module '@fontsource-variable/jetbrains-mono'
declare module 'virtual:plugin-bridge-source' {
const source: string
export default source
}
-9
View File
@@ -404,15 +404,6 @@ export function getPluginInstances(options: RequestOptions = {}) {
})
}
export function createPluginInstance(data: ConfigurePluginInstanceRequest, options: RequestOptions = {}) {
return request<PluginInstanceMutationResponse>({
url: '/api/admin/plugins/instances',
method: 'POST',
data,
...options,
})
}
export function updatePluginInstance(data: { id: string, instance: ConfigurePluginInstanceRequest }, options: RequestOptions = {}) {
return request<PluginInstanceMutationResponse>({
url: '/api/admin/plugins/instances/update',
@@ -0,0 +1,36 @@
import type { Ref } from 'vue'
import { toast } from '@codex-proxy/ui'
import { ApiError } from '@/api/request'
import { errorMessage } from '@/utils/operation'
export type PluginActionContext = ReturnType<typeof usePluginActions>
export function usePluginActions(refresh: (silent?: boolean, suppressErrors?: boolean) => Promise<void>) {
function notifyError(title: string, error: unknown) {
if (error instanceof ApiError && error.kind === 'cancelled')
return
toast.error(errorMessage(error, title))
}
async function runAction<T>(
flag: Ref<boolean>,
title: string,
task: () => Promise<T>,
): Promise<T | undefined> {
if (flag.value)
return undefined
flag.value = true
try {
return await task()
}
catch (error) {
notifyError(title, error)
return undefined
}
finally {
flag.value = false
}
}
return { refresh, notifyError, runAction }
}
@@ -0,0 +1,38 @@
import type { PluginActionContext } from './usePluginActions'
import type { PluginArtifact } from '@/api'
import { toast } from '@codex-proxy/ui'
import { shallowRef } from 'vue'
import { deletePluginArtifact } from '@/api'
export function usePluginArtifactActions({ refresh, notifyError }: Pick<PluginActionContext, 'refresh' | 'notifyError'>) {
const showArtifactDelete = shallowRef(false)
const pendingArtifact = shallowRef<PluginArtifact | null>(null)
const busyDigest = shallowRef('')
function requestArtifactDelete(artifact: PluginArtifact) {
pendingArtifact.value = artifact
showArtifactDelete.value = true
}
async function confirmArtifactDelete() {
const artifact = pendingArtifact.value
if (!artifact || busyDigest.value)
return
busyDigest.value = artifact.metadata.sha256
try {
await deletePluginArtifact({ sha256: artifact.metadata.sha256 }, { silent: true })
showArtifactDelete.value = false
pendingArtifact.value = null
toast.success('插件制品已删除')
await refresh(true)
}
catch (error) {
notifyError('插件制品删除失败', error)
}
finally {
busyDigest.value = ''
}
}
return { showArtifactDelete, pendingArtifact, busyDigest, requestArtifactDelete, confirmArtifactDelete }
}
@@ -0,0 +1,52 @@
import type { Ref } from 'vue'
import type { PluginActionContext } from './usePluginActions'
import type { CreatePluginSourceCredentialRequest, PluginSourceCredential } from '@/api'
import { toast } from '@codex-proxy/ui'
import { shallowRef } from 'vue'
import { createPluginSourceCredential, deletePluginSourceCredential } from '@/api'
interface CredentialContext extends PluginActionContext {
credentials: Ref<PluginSourceCredential[]>
}
export function usePluginCredentials({ credentials, refresh, notifyError, runAction }: CredentialContext) {
const savingCredential = shallowRef(false)
const showCredentialDelete = shallowRef(false)
const pendingCredential = shallowRef<PluginSourceCredential | null>(null)
const busyDistributionId = shallowRef('')
async function saveCredential(request: CreatePluginSourceCredentialRequest) {
const result = await runAction(savingCredential, '下载认证保存失败', () => createPluginSourceCredential(request, { silent: true }))
if (!result)
return null
credentials.value = [...credentials.value, result]
return result
}
function requestCredentialDelete(credential: PluginSourceCredential) {
pendingCredential.value = credential
showCredentialDelete.value = true
}
async function confirmCredentialDelete() {
const credential = pendingCredential.value
if (!credential || busyDistributionId.value)
return
busyDistributionId.value = credential.id
try {
await deletePluginSourceCredential({ id: credential.id }, { silent: true })
showCredentialDelete.value = false
pendingCredential.value = null
toast.success('来源凭据已删除')
await refresh(true)
}
catch (error) {
notifyError('来源凭据删除失败', error)
}
finally {
busyDistributionId.value = ''
}
}
return { savingCredential, showCredentialDelete, pendingCredential, busyDistributionId, saveCredential, requestCredentialDelete, confirmCredentialDelete }
}
@@ -0,0 +1,199 @@
import type { Ref } from 'vue'
import type { PluginActionContext } from './usePluginActions'
import type { ConfigurePluginInstanceRequest, PluginArtifact, PluginInstance, PluginVersionPlan } from '@/api'
import { toast } from '@codex-proxy/ui'
import { computed, shallowRef, watch } from 'vue'
import { deletePluginInstance, disablePluginInstance, getPluginArtifacts, getPluginInstances, updatePluginInstance } from '@/api'
import { configurationStatus } from '../utils/catalog'
interface InstanceContext extends PluginActionContext {
artifacts: Ref<PluginArtifact[]>
onEdit: (pluginId: string) => void
onSaved: () => void
}
export function usePluginInstances({ artifacts, refresh, notifyError, runAction, onEdit, onSaved }: InstanceContext) {
const configurationArtifact = shallowRef<PluginArtifact | null>(null)
const showInstance = shallowRef(false)
const configurationDraft = shallowRef<PluginVersionPlan | null>(null)
const configurationError = shallowRef('')
const editingInstance = shallowRef<PluginInstance | null>(null)
const savingInstance = shallowRef(false)
const pendingEnable = shallowRef<{
request: ConfigurePluginInstanceRequest
instanceId?: string
artifact: PluginArtifact
replacements: (Pick<PluginInstance, 'id' | 'name' | 'revision'> & { version?: string })[]
} | null>(null)
const showEnableConfirmation = computed({
get: () => pendingEnable.value !== null,
set: (open: boolean) => {
if (!open && !savingInstance.value)
pendingEnable.value = null
},
})
const showInstanceDelete = shallowRef(false)
const pendingDeleteInstance = shallowRef<PluginInstance | null>(null)
const busyInstanceId = shallowRef('')
function openEditInstance(instance: PluginInstance) {
configurationDraft.value = null
configurationError.value = ''
configurationArtifact.value = artifacts.value.find(value => value.metadata.sha256 === instance.artifactSha256) ?? null
onEdit(configurationArtifact.value?.metadata.pluginId ?? '')
editingInstance.value = instance
showInstance.value = true
}
async function saveInstance(request: ConfigurePluginInstanceRequest) {
if (savingInstance.value || pendingEnable.value)
return
const instance = editingInstance.value
if (!instance)
return
const input = { ...request, expectedRevision: configurationDraft.value?.instanceRevision ?? instance.revision }
const result = await runAction(savingInstance, '插件配置保存失败', async () => {
if (request.enabled) {
const pending = await prepareInstanceEnable(input, instance?.id)
if (pending.replacements.length) {
pendingEnable.value = pending
return true
}
}
await persistInstance(input, instance?.id)
return true
})
if (!result)
await refresh(true, true)
}
async function prepareInstanceEnable(request: ConfigurePluginInstanceRequest, instanceId?: string) {
// 启用入口共用确认快照,不自动停用确认后出现或修改的配置。
const [currentArtifacts, currentInstances] = await Promise.all([
getPluginArtifacts({ silent: true }),
getPluginInstances({ silent: true }),
])
const artifact = currentArtifacts.find(artifact => artifact.metadata.sha256 === request.artifactSha256)
if (!artifact)
throw new Error('所选版本已不存在,请重新选择')
const digests = new Set(currentArtifacts.filter(value => value.metadata.pluginId === artifact.metadata.pluginId).map(value => value.metadata.sha256))
const replacements = currentInstances
.filter(current => current.enabled && current.id !== (instanceId ?? request.creationId) && digests.has(current.artifactSha256))
.map(({ id, name, revision, artifactSha256 }) => ({ id, name, revision, version: currentArtifacts.find(value => value.metadata.sha256 === artifactSha256)?.metadata.version }))
return { request, instanceId, artifact, replacements }
}
async function requestInstanceEnable(instance: PluginInstance) {
if ((instance.enabled && configurationStatus(instance) !== 'failed') || savingInstance.value || pendingEnable.value)
return
if (instance.configurationRequired) {
openEditInstance(instance)
return
}
const result = await runAction(savingInstance, '启用配置加载失败', async () => {
// 不传 secrets,沿用已保存密钥,不读取或回填明文。
const pending = await prepareInstanceEnable({
expectedRevision: instance.revision,
name: instance.name,
artifactSha256: instance.artifactSha256,
enabled: true,
configuration: instance.configuration,
bindings: instance.bindings,
}, instance.id)
if (pending.replacements.length)
pendingEnable.value = pending
else await persistInstance(pending.request, instance.id)
return true
})
if (!result)
await refresh(true, true)
}
async function confirmInstanceEnable() {
const pending = pendingEnable.value
if (!pending || savingInstance.value)
return
const result = await runAction(savingInstance, '配置启用失败', async () => {
await persistInstance({
...pending.request,
replaceInstances: pending.replacements.map(instance => ({ id: instance.id, expectedRevision: instance.revision })),
}, pending.instanceId)
return true
})
// 失败后保留编辑草稿或已保存配置,再次提交需重新读取并确认停用范围。
pendingEnable.value = null
if (!result)
await refresh(true, true)
}
async function persistInstance(request: ConfigurePluginInstanceRequest, instanceId?: string) {
if (!instanceId)
return
await updatePluginInstance({ id: instanceId, instance: request }, { silent: true })
pendingEnable.value = null
showInstance.value = false
editingInstance.value = null
toast.success(request.replaceInstances?.length ? '已切换当前配置' : request.enabled ? '插件设置已应用' : '设置已保存,插件保持停用')
await refresh(true)
onSaved()
}
async function requestInstanceDisable(instance: PluginInstance) {
if (busyInstanceId.value)
return
busyInstanceId.value = instance.id
try {
await disablePluginInstance({ id: instance.id }, { silent: true })
toast.success('插件已停用')
await refresh(true)
}
catch (error) {
notifyError('插件停用失败', error)
}
finally {
busyInstanceId.value = ''
}
}
function requestInstanceDelete(instance: PluginInstance) {
pendingDeleteInstance.value = instance
showInstanceDelete.value = true
}
async function confirmInstanceDelete() {
const instance = pendingDeleteInstance.value
if (!instance || busyInstanceId.value)
return
busyInstanceId.value = instance.id
try {
await deletePluginInstance({ id: instance.id }, { silent: true })
showInstanceDelete.value = false
pendingDeleteInstance.value = null
toast.success('配置、密钥与私有数据已删除,无法恢复')
await refresh(true)
}
catch (error) {
notifyError('插件实例删除失败', error)
}
finally {
busyInstanceId.value = ''
}
}
watch(showInstance, (open) => {
if (!open && !savingInstance.value) {
editingInstance.value = null
pendingEnable.value = null
}
})
function editVersionPlan(instance: PluginInstance, artifact: PluginArtifact, plan: PluginVersionPlan, error: string) {
configurationArtifact.value = artifact
configurationDraft.value = plan
configurationError.value = error
editingInstance.value = instance
showInstance.value = true
}
return { configurationArtifact, configurationDraft, configurationError, showInstance, editingInstance, savingInstance, pendingEnable, showEnableConfirmation, busyInstanceId, showInstanceDelete, pendingDeleteInstance, openEditInstance, saveInstance, requestInstanceEnable, confirmInstanceEnable, requestInstanceDisable, requestInstanceDelete, confirmInstanceDelete, editVersionPlan }
}
@@ -1,43 +1,30 @@
import type { Ref } from 'vue'
import type { PluginUpdateSelection } from './usePluginUpdateCheck'
import type {
ConfigurePluginInstanceRequest,
CreatePluginSourceCredentialRequest,
PluginArtifact,
PluginArtifactMutationResponse,
PluginInstance,
PluginRollbackPlan,
PluginSourceCredential,
PluginUpdateSourceBinding,
PluginVersionPlan,
} from '@/api'
import { toast } from '@codex-proxy/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue'
import {
createPluginSourceCredential,
deletePluginArtifact,
deletePluginInstance,
deletePluginSourceCredential,
disablePluginInstance,
getPluginArtifacts,
getPluginInstances,
getPluginRollbackPlan,
getPluginSourceCredentials,
getPluginUpdateSources,
getPluginVersionPlan,
rollbackPluginInstance,
switchPluginVersion,
updatePluginInstance,
} from '@/api'
import { ApiError } from '@/api/request'
import { usePluginViewsStore } from '@/stores/modules/plugin-views'
import { errorMessage } from '@/utils/operation'
import { configurationStatus, currentPluginInstance, groupInstalledPlugins } from '../utils/catalog'
import { currentPluginInstance, groupInstalledPlugins } from '../utils/catalog'
import { usePluginActions } from './usePluginActions'
import { usePluginArtifactActions } from './usePluginArtifactActions'
import { usePluginCredentials } from './usePluginCredentials'
import { usePluginInstallation } from './usePluginInstallation'
import { usePluginInstances } from './usePluginInstances'
import { usePluginUninstall } from './usePluginUninstall'
import { usePluginUpdateCheck } from './usePluginUpdateCheck'
import { usePluginVersions } from './usePluginVersions'
export function usePluginManagement() {
const artifacts = shallowRef<PluginArtifact[]>([])
@@ -52,58 +39,11 @@ export function usePluginManagement() {
const selectedPlugin = computed(() => catalog.value.find(plugin => plugin.id === selectedPluginId.value) ?? null)
const showDetail = shallowRef(false)
const detailSection = shallowRef<'configurations' | 'versions'>('configurations')
const configurationArtifact = shallowRef<PluginArtifact | null>(null)
const showInstance = shallowRef(false)
const configurationDraft = shallowRef<PluginVersionPlan | null>(null)
const configurationError = shallowRef('')
const editingInstance = shallowRef<PluginInstance | null>(null)
const savingInstance = shallowRef(false)
const pendingEnable = shallowRef<{
request: ConfigurePluginInstanceRequest
instanceId?: string
artifact: PluginArtifact
replacements: (Pick<PluginInstance, 'id' | 'name' | 'revision'> & { version?: string })[]
} | null>(null)
const showEnableConfirmation = computed({
get: () => pendingEnable.value !== null,
set: (open: boolean) => {
if (!open && !savingInstance.value)
pendingEnable.value = null
},
})
const showRollback = shallowRef(false)
const rollbackInstance = shallowRef<PluginInstance | null>(null)
const rollbackPlan = shallowRef<PluginRollbackPlan | null>(null)
const loadingRollback = shallowRef(false)
const savingCredential = shallowRef(false)
const showArtifactDelete = shallowRef(false)
const pendingArtifact = shallowRef<PluginArtifact | null>(null)
const showInstanceDelete = shallowRef(false)
const pendingDeleteInstance = shallowRef<PluginInstance | null>(null)
const showCredentialDelete = shallowRef(false)
const pendingCredential = shallowRef<PluginSourceCredential | null>(null)
const busyDigest = shallowRef('')
const busyInstanceId = shallowRef('')
const busyDistributionId = shallowRef('')
const pendingVersionSwitch = shallowRef<{ instance: PluginInstance, artifact: PluginArtifact, currentVersion: string } | null>(null)
const showVersionSwitch = computed({
get: () => pendingVersionSwitch.value !== null,
set: (open: boolean) => {
if (!open && !busyInstanceId.value)
pendingVersionSwitch.value = null
},
})
let refreshController: AbortController | undefined
let rollbackController: AbortController | undefined
function notifyError(title: string, error: unknown) {
if (error instanceof ApiError && error.kind === 'cancelled')
return
toast.error(errorMessage(error, title))
}
const actions = usePluginActions(refresh)
const { notifyError, runAction } = actions
async function refresh(silent = false, suppressErrors = false) {
refreshController?.abort()
@@ -144,25 +84,29 @@ export function usePluginManagement() {
}
}
async function runAction<T>(
flag: Ref<boolean>,
title: string,
task: () => Promise<T>,
): Promise<T | undefined> {
if (flag.value)
return undefined
flag.value = true
try {
return await task()
}
catch (error) {
notifyError(title, error)
return undefined
}
finally {
flag.value = false
}
const artifactActions = usePluginArtifactActions(actions)
const credentialActions = usePluginCredentials({ ...actions, credentials })
function showConfigurations() {
detailSection.value = 'configurations'
showDetail.value = true
}
const instanceActions = usePluginInstances({
...actions,
artifacts,
onEdit: pluginId => selectedPluginId.value = pluginId,
onSaved: showConfigurations,
})
const versionActions = usePluginVersions({
...actions,
catalog,
instances,
busyInstanceId: instanceActions.busyInstanceId,
onApplied: showConfigurations,
onConfigurationRequired: (...args) => {
instanceActions.editVersionPlan(...args)
showDetail.value = false
},
})
const installation = usePluginInstallation({
runAction,
@@ -187,51 +131,26 @@ export function usePluginManagement() {
selectedPluginId.value = artifact.metadata.pluginId
await refresh(true)
if (switchTarget) {
await applyVersionSwitch(switchTarget, artifact)
await versionActions.applyVersionSwitch(switchTarget, artifact)
return
}
if (configurationRequired && defaultInstanceId) {
const instance = instances.value.find(instance => instance.id === defaultInstanceId)
if (instance) {
openEditInstance(instance)
instanceActions.openEditInstance(instance)
return
}
}
const plugin = catalog.value.find(plugin => plugin.id === artifact.metadata.pluginId)
const current = plugin && currentPluginInstance(plugin)
if (current && current.artifactSha256 !== artifact.metadata.sha256) {
requestVersionSwitch(artifact)
versionActions.requestVersionSwitch(artifact)
return
}
detailSection.value = 'configurations'
showDetail.value = true
}
function requestArtifactDelete(artifact: PluginArtifact) {
pendingArtifact.value = artifact
showArtifactDelete.value = true
}
async function confirmArtifactDelete() {
const artifact = pendingArtifact.value
if (!artifact || busyDigest.value)
return
busyDigest.value = artifact.metadata.sha256
try {
await deletePluginArtifact({ sha256: artifact.metadata.sha256 }, { silent: true })
showArtifactDelete.value = false
pendingArtifact.value = null
toast.success('插件制品已删除')
await refresh(true)
}
catch (error) {
notifyError('插件制品删除失败', error)
}
finally {
busyDigest.value = ''
}
}
function openDetail(pluginId: string) {
selectedPluginId.value = pluginId
const plugin = catalog.value.find(value => value.id === pluginId)
@@ -239,303 +158,6 @@ export function usePluginManagement() {
showDetail.value = true
}
function openEditInstance(instance: PluginInstance) {
configurationDraft.value = null
configurationError.value = ''
configurationArtifact.value = artifacts.value.find(value => value.metadata.sha256 === instance.artifactSha256) ?? null
selectedPluginId.value = configurationArtifact.value?.metadata.pluginId ?? ''
editingInstance.value = instance
showInstance.value = true
}
async function saveInstance(request: ConfigurePluginInstanceRequest) {
if (savingInstance.value || pendingEnable.value)
return
const instance = editingInstance.value
if (!instance)
return
const input = { ...request, expectedRevision: configurationDraft.value?.instanceRevision ?? instance.revision }
const result = await runAction(savingInstance, '插件配置保存失败', async () => {
if (request.enabled) {
const pending = await prepareInstanceEnable(input, instance?.id)
if (pending.replacements.length) {
pendingEnable.value = pending
return true
}
}
await persistInstance(input, instance?.id)
return true
})
if (!result)
await refresh(true, true)
}
async function prepareInstanceEnable(request: ConfigurePluginInstanceRequest, instanceId?: string) {
// 启用入口共用确认快照,不自动停用确认后出现或修改的配置。
const [currentArtifacts, currentInstances] = await Promise.all([
getPluginArtifacts({ silent: true }),
getPluginInstances({ silent: true }),
])
const artifact = currentArtifacts.find(artifact => artifact.metadata.sha256 === request.artifactSha256)
if (!artifact)
throw new Error('所选版本已不存在,请重新选择')
const digests = new Set(currentArtifacts.filter(value => value.metadata.pluginId === artifact.metadata.pluginId).map(value => value.metadata.sha256))
const replacements = currentInstances
.filter(current => current.enabled && current.id !== (instanceId ?? request.creationId) && digests.has(current.artifactSha256))
.map(({ id, name, revision, artifactSha256 }) => ({ id, name, revision, version: currentArtifacts.find(value => value.metadata.sha256 === artifactSha256)?.metadata.version }))
return { request, instanceId, artifact, replacements }
}
async function requestInstanceEnable(instance: PluginInstance) {
if ((instance.enabled && configurationStatus(instance) !== 'failed') || savingInstance.value || pendingEnable.value)
return
if (instance.configurationRequired) {
openEditInstance(instance)
return
}
const result = await runAction(savingInstance, '启用配置加载失败', async () => {
// 不传 secrets,沿用已保存密钥,不读取或回填明文。
const pending = await prepareInstanceEnable({
expectedRevision: instance.revision,
name: instance.name,
artifactSha256: instance.artifactSha256,
enabled: true,
configuration: instance.configuration,
bindings: instance.bindings,
}, instance.id)
if (pending.replacements.length)
pendingEnable.value = pending
else await persistInstance(pending.request, instance.id)
return true
})
if (!result)
await refresh(true, true)
}
async function confirmInstanceEnable() {
const pending = pendingEnable.value
if (!pending || savingInstance.value)
return
const result = await runAction(savingInstance, '配置启用失败', async () => {
await persistInstance({
...pending.request,
replaceInstances: pending.replacements.map(instance => ({ id: instance.id, expectedRevision: instance.revision })),
}, pending.instanceId)
return true
})
// 失败后保留编辑草稿或已保存配置,再次提交需重新读取并确认停用范围。
pendingEnable.value = null
if (!result)
await refresh(true, true)
}
async function persistInstance(request: ConfigurePluginInstanceRequest, instanceId?: string) {
if (!instanceId)
return
await updatePluginInstance({ id: instanceId, instance: request }, { silent: true })
pendingEnable.value = null
showInstance.value = false
editingInstance.value = null
toast.success(request.replaceInstances?.length ? '已切换当前配置' : request.enabled ? '插件设置已应用' : '设置已保存,插件保持停用')
await refresh(true)
detailSection.value = 'configurations'
showDetail.value = true
}
async function requestInstanceDisable(instance: PluginInstance) {
if (busyInstanceId.value)
return
busyInstanceId.value = instance.id
try {
await disablePluginInstance({ id: instance.id }, { silent: true })
toast.success('插件已停用')
await refresh(true)
}
catch (error) {
notifyError('插件停用失败', error)
}
finally {
busyInstanceId.value = ''
}
}
function requestVersionSwitch(artifact: PluginArtifact) {
const plugin = catalog.value.find(plugin => plugin.id === artifact.metadata.pluginId)
const instance = plugin && currentPluginInstance(plugin)
if (!plugin || !instance || !artifact.acceptedAt || busyInstanceId.value || instance.artifactSha256 === artifact.metadata.sha256)
return
pendingVersionSwitch.value = { instance, artifact, currentVersion: plugin.artifact.metadata.version }
}
async function confirmVersionSwitch() {
if (!pendingVersionSwitch.value || busyInstanceId.value)
return
const { instance, artifact } = pendingVersionSwitch.value
await applyVersionSwitch(instance, artifact)
}
async function applyVersionSwitch(instance: PluginInstance, artifact: PluginArtifact) {
busyInstanceId.value = instance.id
try {
await switchPluginVersion({
id: instance.id,
target: { artifactSha256: artifact.metadata.sha256, expectedRevision: instance.revision },
}, { silent: true })
pendingVersionSwitch.value = null
toast.success(`已切换至 ${artifact.metadata.version}`)
await refresh(true)
detailSection.value = 'configurations'
showDetail.value = true
}
catch (error) {
await refresh(true, true)
const current = instances.value.find(value => value.id === instance.id)
if (error instanceof ApiError && error.status === 400 && current?.revision === instance.revision) {
try {
const plan = await getPluginVersionPlan(instance.id, artifact.metadata.sha256, { silent: true })
if (plan.instanceRevision !== instance.revision)
throw new Error('插件设置已变更,请刷新后重试')
configurationArtifact.value = artifact
pendingVersionSwitch.value = null
configurationDraft.value = plan
configurationError.value = errorMessage(error, '请调整不兼容的设置后重试')
editingInstance.value = current
showDetail.value = false
showInstance.value = true
}
catch (planError) {
notifyError('版本设置加载失败', planError)
}
}
else {
notifyError('版本切换失败', error)
}
}
finally {
busyInstanceId.value = ''
}
}
async function openRollback(instance: PluginInstance) {
rollbackController?.abort()
const controller = new AbortController()
rollbackController = controller
rollbackInstance.value = instance
rollbackPlan.value = null
showRollback.value = true
loadingRollback.value = true
try {
const plan = await getPluginRollbackPlan(instance.id, { signal: controller.signal, silent: true })
if (rollbackController === controller)
rollbackPlan.value = plan
}
catch (error) {
if (rollbackController === controller)
notifyError('回退版本加载失败', error)
}
finally {
if (rollbackController === controller) {
rollbackController = undefined
loadingRollback.value = false
}
}
}
async function confirmRollback(artifactSha256: string) {
const instance = rollbackInstance.value
const plan = rollbackPlan.value
const target = plan?.targets.find(target => target.artifactSha256 === artifactSha256)
if (!instance || !plan || !target || busyInstanceId.value)
return
busyInstanceId.value = instance.id
try {
await rollbackPluginInstance({ id: instance.id, target: { artifactSha256, expectedRevision: plan.instanceRevision } }, { silent: true })
showRollback.value = false
rollbackInstance.value = null
rollbackPlan.value = null
toast.success(`已回退至 ${target.version},并恢复对应设置`)
await refresh(true)
}
catch (error) {
notifyError('插件版本回退失败', error)
}
finally {
busyInstanceId.value = ''
}
}
function requestInstanceDelete(instance: PluginInstance) {
pendingDeleteInstance.value = instance
showInstanceDelete.value = true
}
async function confirmInstanceDelete() {
const instance = pendingDeleteInstance.value
if (!instance || busyInstanceId.value)
return
busyInstanceId.value = instance.id
try {
await deletePluginInstance({ id: instance.id }, { silent: true })
showInstanceDelete.value = false
pendingDeleteInstance.value = null
toast.success('配置、密钥与私有数据已删除,无法恢复')
await refresh(true)
}
catch (error) {
notifyError('插件实例删除失败', error)
}
finally {
busyInstanceId.value = ''
}
}
async function saveCredential(request: CreatePluginSourceCredentialRequest) {
const result = await runAction(savingCredential, '下载认证保存失败', () => createPluginSourceCredential(request, { silent: true }))
if (!result)
return null
credentials.value = [...credentials.value, result]
return result
}
function requestCredentialDelete(credential: PluginSourceCredential) {
pendingCredential.value = credential
showCredentialDelete.value = true
}
async function confirmCredentialDelete() {
const credential = pendingCredential.value
if (!credential || busyDistributionId.value)
return
busyDistributionId.value = credential.id
try {
await deletePluginSourceCredential({ id: credential.id }, { silent: true })
showCredentialDelete.value = false
pendingCredential.value = null
toast.success('来源凭据已删除')
await refresh(true)
}
catch (error) {
notifyError('来源凭据删除失败', error)
}
finally {
busyDistributionId.value = ''
}
}
watch(showInstance, (open) => {
if (!open && !savingInstance.value) {
editingInstance.value = null
pendingEnable.value = null
}
})
watch(showRollback, (open) => {
if (!open) {
rollbackController?.abort()
rollbackController = undefined
loadingRollback.value = false
}
})
onMounted(() => void refresh())
const uninstall = usePluginUninstall(() => refresh(true), notifyError)
let pollTimer: ReturnType<typeof setTimeout> | undefined
@@ -560,11 +182,14 @@ export function usePluginManagement() {
disposed = true
clearTimeout(pollTimer)
refreshController?.abort()
rollbackController?.abort()
})
return {
...installation,
...artifactActions,
...credentialActions,
...instanceActions,
...versionActions,
updateCheck,
upgradeCheckedPlugin,
catalog,
@@ -572,9 +197,6 @@ export function usePluginManagement() {
showDetail,
detailSection,
openDetail,
configurationArtifact,
configurationDraft,
configurationError,
uninstall,
artifacts,
instances,
@@ -582,43 +204,6 @@ export function usePluginManagement() {
credentials,
extensions,
loading,
showInstance,
editingInstance,
savingInstance,
pendingEnable,
showEnableConfirmation,
confirmInstanceEnable,
showRollback,
rollbackInstance,
rollbackPlan,
loadingRollback,
openRollback,
confirmRollback,
savingCredential,
requestVersionSwitch,
confirmVersionSwitch,
pendingVersionSwitch,
showVersionSwitch,
showArtifactDelete,
pendingArtifact,
showInstanceDelete,
pendingDeleteInstance,
showCredentialDelete,
pendingCredential,
busyDigest,
busyInstanceId,
busyDistributionId,
refresh,
requestArtifactDelete,
confirmArtifactDelete,
openEditInstance,
saveInstance,
requestInstanceEnable,
requestInstanceDisable,
requestInstanceDelete,
confirmInstanceDelete,
saveCredential,
requestCredentialDelete,
confirmCredentialDelete,
}
}
@@ -0,0 +1,146 @@
import type { Ref } from 'vue'
import type { InstalledPlugin } from '../utils/catalog'
import type { PluginActionContext } from './usePluginActions'
import type { PluginArtifact, PluginInstance, PluginRollbackPlan, PluginVersionPlan } from '@/api'
import { toast } from '@codex-proxy/ui'
import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { getPluginRollbackPlan, getPluginVersionPlan, rollbackPluginInstance, switchPluginVersion } from '@/api'
import { ApiError } from '@/api/request'
import { errorMessage } from '@/utils/operation'
import { currentPluginInstance } from '../utils/catalog'
interface VersionContext extends Pick<PluginActionContext, 'refresh' | 'notifyError'> {
catalog: Ref<InstalledPlugin[]>
instances: Ref<PluginInstance[]>
busyInstanceId: Ref<string>
onApplied: () => void
onConfigurationRequired: (instance: PluginInstance, artifact: PluginArtifact, plan: PluginVersionPlan, error: string) => void
}
export function usePluginVersions({ catalog, instances, busyInstanceId, refresh, notifyError, onApplied, onConfigurationRequired }: VersionContext) {
const showRollback = shallowRef(false)
const rollbackInstance = shallowRef<PluginInstance | null>(null)
const rollbackPlan = shallowRef<PluginRollbackPlan | null>(null)
const loadingRollback = shallowRef(false)
const pendingVersionSwitch = shallowRef<{ instance: PluginInstance, artifact: PluginArtifact, currentVersion: string } | null>(null)
const showVersionSwitch = computed({
get: () => pendingVersionSwitch.value !== null,
set: (open: boolean) => {
if (!open && !busyInstanceId.value)
pendingVersionSwitch.value = null
},
})
let rollbackController: AbortController | undefined
function requestVersionSwitch(artifact: PluginArtifact) {
const plugin = catalog.value.find(plugin => plugin.id === artifact.metadata.pluginId)
const instance = plugin && currentPluginInstance(plugin)
if (!plugin || !instance || !artifact.acceptedAt || busyInstanceId.value || instance.artifactSha256 === artifact.metadata.sha256)
return
pendingVersionSwitch.value = { instance, artifact, currentVersion: plugin.artifact.metadata.version }
}
async function confirmVersionSwitch() {
if (!pendingVersionSwitch.value || busyInstanceId.value)
return
const { instance, artifact } = pendingVersionSwitch.value
await applyVersionSwitch(instance, artifact)
}
async function applyVersionSwitch(instance: PluginInstance, artifact: PluginArtifact) {
busyInstanceId.value = instance.id
try {
await switchPluginVersion({
id: instance.id,
target: { artifactSha256: artifact.metadata.sha256, expectedRevision: instance.revision },
}, { silent: true })
pendingVersionSwitch.value = null
toast.success(`已切换至 ${artifact.metadata.version}`)
await refresh(true)
onApplied()
}
catch (error) {
await refresh(true, true)
const current = instances.value.find(value => value.id === instance.id)
if (error instanceof ApiError && error.status === 400 && current?.revision === instance.revision) {
try {
const plan = await getPluginVersionPlan(instance.id, artifact.metadata.sha256, { silent: true })
if (plan.instanceRevision !== instance.revision)
throw new Error('插件设置已变更,请刷新后重试')
pendingVersionSwitch.value = null
onConfigurationRequired(current, artifact, plan, errorMessage(error, '请调整不兼容的设置后重试'))
}
catch (planError) {
notifyError('版本设置加载失败', planError)
}
}
else {
notifyError('版本切换失败', error)
}
}
finally {
busyInstanceId.value = ''
}
}
async function openRollback(instance: PluginInstance) {
rollbackController?.abort()
const controller = new AbortController()
rollbackController = controller
rollbackInstance.value = instance
rollbackPlan.value = null
showRollback.value = true
loadingRollback.value = true
try {
const plan = await getPluginRollbackPlan(instance.id, { signal: controller.signal, silent: true })
if (rollbackController === controller)
rollbackPlan.value = plan
}
catch (error) {
if (rollbackController === controller)
notifyError('回退版本加载失败', error)
}
finally {
if (rollbackController === controller) {
rollbackController = undefined
loadingRollback.value = false
}
}
}
async function confirmRollback(artifactSha256: string) {
const instance = rollbackInstance.value
const plan = rollbackPlan.value
const target = plan?.targets.find(target => target.artifactSha256 === artifactSha256)
if (!instance || !plan || !target || busyInstanceId.value)
return
busyInstanceId.value = instance.id
try {
await rollbackPluginInstance({ id: instance.id, target: { artifactSha256, expectedRevision: plan.instanceRevision } }, { silent: true })
showRollback.value = false
rollbackInstance.value = null
rollbackPlan.value = null
toast.success(`已回退至 ${target.version},并恢复对应设置`)
await refresh(true)
}
catch (error) {
notifyError('插件版本回退失败', error)
}
finally {
busyInstanceId.value = ''
}
}
watch(showRollback, (open) => {
if (!open) {
rollbackController?.abort()
rollbackController = undefined
loadingRollback.value = false
}
})
onScopeDispose(() => rollbackController?.abort())
return { showRollback, rollbackInstance, rollbackPlan, loadingRollback, pendingVersionSwitch, showVersionSwitch, requestVersionSwitch, confirmVersionSwitch, applyVersionSwitch, openRollback, confirmRollback }
}
@@ -0,0 +1,37 @@
export const PLUGIN_MANAGEMENT_BRIDGE = 'codex-proxy-plugin-management'
export const PLUGIN_MANAGEMENT_BRIDGE_VERSION = 2
export const MAXIMUM_MANAGEMENT_BODY_BYTES = 1024 * 1024
export const MAXIMUM_MANAGEMENT_QUERY_BYTES = 8192
export const MAXIMUM_MANAGEMENT_IN_FLIGHT = 8
export const MAXIMUM_MODEL_BODY_BYTES = 8 * 1024 * 1024
export const MAXIMUM_MODEL_CHUNK_BYTES = 64 * 1024
export const MAXIMUM_MODEL_IN_FLIGHT = 4
export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000
export const MODEL_RESPONSE_IDLE_MS = 30 * 1000
export interface PluginFrameTheme {
name: 'light' | 'dark'
tokens: Record<string, string>
}
export interface PluginBridgeInitial {
target: string
version: number
channel: string
session: string
parentOrigin: string
plugin: { name: string }
page: { id: string, title: string }
theme: PluginFrameTheme
viewportHeight: number
resourceUrls: Record<string, string>
managementTimeoutMs: number
apiTimeoutMs: number
}
export function hasControlCharacter(value: string) {
return [...value].some((character) => {
const code = character.codePointAt(0) ?? 0
return code < 0x20 || code === 0x7F
})
}
@@ -0,0 +1,475 @@
import type { PluginBridgeInitial } from './bridge-contract'
import { hasControlCharacter, MAXIMUM_MANAGEMENT_BODY_BYTES, MAXIMUM_MANAGEMENT_IN_FLIGHT, MAXIMUM_MANAGEMENT_QUERY_BYTES, MAXIMUM_MODEL_BODY_BYTES, MAXIMUM_MODEL_CHUNK_BYTES, MAXIMUM_MODEL_IN_FLIGHT, MODEL_REQUEST_DEADLINE_MS } from './bridge-contract'
interface PendingOperation {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: number
}
interface ModelOperation {
id: number
resolve: (value: Response) => void
reject: (error: Error) => void
signal?: AbortSignal
abort?: () => void
timer?: number
controller?: ReadableStreamDefaultController<Uint8Array<ArrayBuffer>>
responseStarted: boolean
pullPromise?: Promise<void>
pullResolve?: () => void
pullReject?: (error: Error) => void
pullSequence: number
pullId?: number
}
interface ModelMessage {
id: number
type: string
payload?: Record<string, unknown>
error?: unknown
}
interface ManagementRequest {
method: string
path: string
query?: string
contentType?: string
body?: string | ArrayBuffer | ArrayBufferView
}
interface ModelRequest {
clientKeyId: string
body: Record<string, unknown>
signal?: AbortSignal
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
export default function installPluginBridge(initial: PluginBridgeInitial) {
const pending = new Map<number, PendingOperation>()
const modelOperations = new Map<number, ModelOperation>()
let sequence = 0
let resizeObserver: ResizeObserver | undefined
let resizeFrame = 0
let previousHeight = 0
let disposed = false
let currentTheme = initial.theme.name
const maximumBodyBytes = MAXIMUM_MANAGEMENT_BODY_BYTES
const maximumQueryBytes = MAXIMUM_MANAGEMENT_QUERY_BYTES
const maximumInFlight = MAXIMUM_MANAGEMENT_IN_FLIGHT
const maximumModelBodyBytes = MAXIMUM_MODEL_BODY_BYTES
const maximumModelChunkBytes = MAXIMUM_MODEL_CHUNK_BYTES
const maximumModelInFlight = MAXIMUM_MODEL_IN_FLIGHT
const modelRequestDeadlineMs = MODEL_REQUEST_DEADLINE_MS
function applyLayout(height: unknown) {
if (typeof height === 'number' && Number.isSafeInteger(height) && height >= 0) {
document.documentElement.style.setProperty('--cp-plugin-viewport-height', `${height}px`)
}
}
function applyTheme(theme: unknown) {
if (!isRecord(theme) || (theme.name !== 'light' && theme.name !== 'dark') || !theme.tokens || typeof theme.tokens !== 'object')
return
const root = document.documentElement
root.dataset.theme = theme.name
root.style.colorScheme = theme.name
for (const [name, value] of Object.entries(theme.tokens)) {
if (typeof name === 'string' && name.startsWith('--cp-') && typeof value === 'string')
root.style.setProperty(name, value)
}
currentTheme = theme.name
window.dispatchEvent(new CustomEvent('codex-proxy-themechange', { detail: { theme: currentTheme } }))
}
async function bodyBytes(body: unknown): Promise<ArrayBuffer> {
if (body == null)
return new ArrayBuffer(0)
if (typeof body === 'string')
return new TextEncoder().encode(body).buffer
if (body instanceof ArrayBuffer)
return body.slice(0)
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength).slice().buffer
throw new TypeError('请求正文只支持 string、ArrayBuffer 或 TypedArray')
}
function nextRequestId() {
sequence += 1
if (!Number.isSafeInteger(sequence))
throw new Error('插件页面请求序号已耗尽')
return sequence
}
function post(type: string, id: number, payload?: unknown, transfer: Transferable[] = []) {
parent.postMessage({
target: initial.target,
version: initial.version,
channel: initial.channel,
session: initial.session,
type,
id,
payload,
}, initial.parentOrigin, transfer)
}
function invoke(kind: string, payload: unknown, transfer: Transferable[] = []): Promise<unknown> {
if (disposed)
return Promise.reject(new Error('插件页面已关闭'))
if (pending.size >= maximumInFlight)
return Promise.reject(new Error('插件管理请求已达并发上限'))
const id = nextRequestId()
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
pending.delete(id)
reject(new Error('插件管理请求超时'))
}, kind === 'request' ? initial.managementTimeoutMs : initial.apiTimeoutMs)
pending.set(id, { resolve, reject, timer })
post(kind, id, payload, transfer)
})
}
function reportContentHeight() {
// 不读 documentElement.scrollHeight,避免当前 iframe 高度阻止内容收缩。
const height = Math.max(1, Math.ceil(document.body.getBoundingClientRect().height))
if (height === previousHeight)
return
previousHeight = height
post('resize', nextRequestId(), { height })
}
async function observeContentHeight() {
// load 后先触发布局,让实际使用的字体进入加载集合,再等待字体与布局就绪。
document.body.getBoundingClientRect()
await document.fonts.ready
if (disposed)
return
// 首次上报不依赖渲染回调,后台或视口外的 iframe 也能完成初始化。
reportContentHeight()
resizeObserver = new ResizeObserver(() => {
if (resizeFrame)
return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0
reportContentHeight()
})
})
resizeObserver.observe(document.body, { box: 'border-box' })
}
window.addEventListener('load', observeContentHeight, { once: true })
async function request(input: ManagementRequest) {
if (!input || typeof input !== 'object')
throw new TypeError('插件管理请求无效')
const method = String(input.method || '').toUpperCase()
const path = String(input.path || '')
const query = input.query == null ? '' : String(input.query)
const contentType = input.contentType == null ? undefined : String(input.contentType)
if (new TextEncoder().encode(query).byteLength > maximumQueryBytes || query.includes('#') || query.startsWith('?'))
throw new TypeError('插件管理查询参数无效')
const body = await bodyBytes(input.body)
if (body.byteLength > maximumBodyBytes)
throw new TypeError('插件管理请求正文超过 1 MiB')
return invoke('request', { method, path, query, contentType, body }, [body])
}
function callbackTicket(input: { path: string, ttlSeconds: number }) {
if (!input || typeof input !== 'object')
return Promise.reject(new TypeError('插件回调票据请求无效'))
return invoke('callback-ticket', { path: String(input.path || ''), ttlSeconds: Number(input.ttlSeconds) })
}
function abortError(reason: unknown) {
return reason instanceof Error ? reason : new DOMException('模型请求已取消', 'AbortError')
}
function clearModelOperation(operation: ModelOperation) {
if (modelOperations.get(operation.id) !== operation)
return
modelOperations.delete(operation.id)
clearTimeout(operation.timer)
if (operation.signal && operation.abort)
operation.signal.removeEventListener('abort', operation.abort)
}
function settleModelPull(operation: ModelOperation, error?: Error) {
const resolve = operation.pullResolve
const reject = operation.pullReject
operation.pullResolve = undefined
operation.pullReject = undefined
operation.pullPromise = undefined
operation.pullId = undefined
if (error)
reject?.(error)
else resolve?.()
}
function failModelOperation(operation: ModelOperation, error: Error, notifyParent: boolean) {
if (modelOperations.get(operation.id) !== operation)
return
clearModelOperation(operation)
settleModelPull(operation, error)
if (operation.controller) {
try {
operation.controller.error(error)
}
catch {}
}
else {
operation.reject(error)
}
if (notifyParent)
post('models-cancel', operation.id)
}
function finishModelOperation(operation: ModelOperation) {
if (modelOperations.get(operation.id) !== operation)
return
clearModelOperation(operation)
settleModelPull(operation)
}
function validateResponseHeaders(value: unknown) {
if (!Array.isArray(value))
throw new Error('模型响应头无效')
for (const entry of value) {
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string')
throw new Error('模型响应头无效')
}
// 与宿主共享浏览器的 Headers 校验,不再按插件身份过滤字段。
return new Headers(value as [string, string][])
}
function handleModelMessage(message: ModelMessage) {
const operation = modelOperations.get(message.id)
if (!operation)
return
if (message.type === 'models-error') {
const text = typeof message.error === 'string' && message.error ? message.error : '模型请求失败'
failModelOperation(operation, new Error(text), false)
return
}
if (message.type === 'models-response') {
if (operation.controller || operation.responseStarted || !message.payload || typeof message.payload !== 'object') {
failModelOperation(operation, new Error('模型响应协议无效'), true)
return
}
try {
const status = message.payload.status
const statusText = message.payload.statusText ?? ''
const hasBody = message.payload.hasBody
if (typeof status !== 'number' || !Number.isSafeInteger(status) || status < 200 || status > 599 || typeof statusText !== 'string' || statusText.length > 256 || hasControlCharacter(statusText) || typeof hasBody !== 'boolean')
throw new Error('模型响应状态无效')
const headers = validateResponseHeaders(message.payload.headers)
operation.responseStarted = true
if (!hasBody) {
finishModelOperation(operation)
operation.resolve(new Response(null, { status, statusText, headers }))
return
}
const stream = new ReadableStream({
start(controller) {
operation.controller = controller
},
pull() {
if (modelOperations.get(operation.id) !== operation)
return undefined
if (operation.pullPromise)
return operation.pullPromise
operation.pullSequence += 1
operation.pullId = operation.pullSequence
operation.pullPromise = new Promise<void>((resolve, reject) => {
operation.pullResolve = resolve
operation.pullReject = reject
post('models-pull', operation.id, { pullId: operation.pullId })
})
return operation.pullPromise
},
cancel() {
if (modelOperations.get(operation.id) === operation) {
clearModelOperation(operation)
settleModelPull(operation)
post('models-cancel', operation.id)
}
},
}, { highWaterMark: 0 })
operation.resolve(new Response(stream, { status, statusText, headers }))
}
catch (error) {
failModelOperation(operation, error instanceof Error ? error : new Error('模型响应协议无效'), true)
}
return
}
if (!operation.controller || !operation.responseStarted) {
failModelOperation(operation, new Error('模型响应协议无效'), true)
return
}
if (message.type === 'models-chunk') {
const pullId = message.payload?.pullId
const body = message.payload?.body
if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId || !(body instanceof ArrayBuffer) || body.byteLength === 0 || body.byteLength > maximumModelChunkBytes) {
failModelOperation(operation, new Error('模型响应分块无效'), true)
return
}
try {
operation.controller.enqueue(new Uint8Array(body))
settleModelPull(operation)
}
catch (error) {
failModelOperation(operation, error instanceof Error ? error : new Error('模型响应读取失败'), true)
}
return
}
if (message.type === 'models-complete') {
const pullId = message.payload?.pullId
if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId) {
failModelOperation(operation, new Error('模型响应结束标记无效'), true)
return
}
try {
operation.controller.close()
}
catch {}
finishModelOperation(operation)
}
}
function modelResponses(input: ModelRequest): Promise<Response> {
if (disposed)
return Promise.reject(new Error('插件页面已关闭'))
if (!input || typeof input !== 'object' || !input.body || typeof input.body !== 'object' || Array.isArray(input.body))
return Promise.reject(new TypeError('Responses 请求正文必须是 JSON 对象'))
if (typeof input.clientKeyId !== 'string' || !input.clientKeyId || input.clientKeyId !== input.clientKeyId.trim() || input.clientKeyId.length > 128 || hasControlCharacter(input.clientKeyId))
return Promise.reject(new TypeError('clientKeyId 无效'))
if (input.signal !== undefined && !(input.signal instanceof AbortSignal))
return Promise.reject(new TypeError('signal 必须是 AbortSignal'))
if (input.signal?.aborted)
return Promise.reject(abortError(input.signal.reason))
if (modelOperations.size >= maximumModelInFlight)
return Promise.reject(new Error('模型请求已达并发上限'))
let body
try {
body = JSON.stringify(input.body)
}
catch {
return Promise.reject(new TypeError('Responses 请求正文必须可序列化为 JSON'))
}
if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > maximumModelBodyBytes)
return Promise.reject(new TypeError('Responses 请求正文超过 8 MiB'))
const id = nextRequestId()
return new Promise((resolve, reject) => {
const operation: ModelOperation = {
id,
resolve,
reject,
signal: input.signal,
abort: undefined,
timer: undefined,
controller: undefined,
responseStarted: false,
pullPromise: undefined,
pullResolve: undefined,
pullReject: undefined,
pullSequence: 0,
pullId: undefined,
}
operation.abort = () => failModelOperation(operation, abortError(input.signal?.reason), true)
operation.timer = window.setTimeout(() => failModelOperation(operation, new Error('模型请求超过 10 分钟'), true), modelRequestDeadlineMs)
modelOperations.set(id, operation)
input.signal?.addEventListener('abort', operation.abort, { once: true })
post('models-request', id, { clientKeyId: input.clientKeyId, body })
})
}
document.addEventListener('click', (event) => {
if (event.defaultPrevented || !(event.target instanceof Element))
return
const submitter = event.target.closest('button, input')
if (!(submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement))
return
const type = submitter.type.toLowerCase()
if (type !== 'submit' && type !== 'image')
return
const form = submitter.form
if (!form)
return
event.preventDefault()
queueMicrotask(() => {
if (!form.isConnected || !submitter.isConnected)
return
if (!submitter.formNoValidate && !form.reportValidity())
return
form.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter }))
})
})
window.addEventListener('message', (event) => {
if (event.source !== parent || event.origin !== initial.parentOrigin)
return
const message: unknown = event.data
if (!isRecord(message) || message.target !== initial.target || message.version !== initial.version || message.channel !== initial.channel || message.session !== initial.session)
return
if (message.type === 'theme') {
applyTheme(message.payload)
return
}
if (message.type === 'layout') {
applyLayout(isRecord(message.payload) ? message.payload.viewportHeight : undefined)
return
}
if (typeof message.id !== 'number' || !Number.isSafeInteger(message.id) || message.id < 1)
return
if (message.type === 'models-response' || message.type === 'models-chunk' || message.type === 'models-complete' || message.type === 'models-error') {
handleModelMessage({ id: message.id, type: message.type, error: message.error, payload: isRecord(message.payload) ? message.payload : undefined })
return
}
if (message.type !== 'result')
return
const operation = pending.get(message.id)
if (!operation)
return
pending.delete(message.id)
clearTimeout(operation.timer)
if (message.ok)
operation.resolve(message.payload)
else operation.reject(new Error(typeof message.error === 'string' ? message.error : '插件管理请求失败'))
})
window.addEventListener('pagehide', () => {
disposed = true
resizeObserver?.disconnect()
cancelAnimationFrame(resizeFrame)
const error = new Error('插件页面已关闭')
for (const operation of pending.values()) {
clearTimeout(operation.timer)
operation.reject(error)
}
pending.clear()
for (const operation of [...modelOperations.values()])
failModelOperation(operation, error, true)
}, { once: true })
const api = {
version: initial.version,
plugin: Object.freeze(initial.plugin),
page: Object.freeze(initial.page),
get theme() { return currentTheme },
request,
callbackTicket,
models: Object.freeze({ responses: modelResponses }),
resourceUrl(path: string) {
const key = String(path)
if (!Object.hasOwn(initial.resourceUrls, key))
throw new Error('资源未注册或不可作为页面子资源')
const value = initial.resourceUrls[key]
return value
},
}
Object.defineProperty(window, 'codexProxyPlugin', { value: Object.freeze(api), configurable: false, writable: false })
applyTheme(initial.theme)
applyLayout(initial.viewportHeight)
}
+10 -389
View File
@@ -1,3 +1,4 @@
import type { PluginBridgeInitial, PluginFrameTheme } from './bridge-contract'
import type {
PluginManagementPage,
PluginManagementResource,
@@ -5,20 +6,13 @@ import type {
} from '@/api'
import { THEME_TOKEN_NAMES } from '@codex-proxy/ui/theme'
import { generate, parse, walk } from 'css-tree'
import bridgeSource from 'virtual:plugin-bridge-source'
import { getPluginManagementResource, PLUGIN_MANAGEMENT_TIMEOUT_MS } from '@/api'
import { API_TIMEOUT_MS } from '@/api/constants'
export const PLUGIN_MANAGEMENT_BRIDGE = 'codex-proxy-plugin-management'
export const PLUGIN_MANAGEMENT_BRIDGE_VERSION = 2
export const MAXIMUM_MANAGEMENT_BODY_BYTES = 1024 * 1024
export const MAXIMUM_MANAGEMENT_QUERY_BYTES = 8192
export const MAXIMUM_MANAGEMENT_IN_FLIGHT = 8
export const MAXIMUM_MODEL_BODY_BYTES = 8 * 1024 * 1024
export const MAXIMUM_MODEL_CHUNK_BYTES = 64 * 1024
export const MAXIMUM_MODEL_IN_FLIGHT = 4
export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000
export const MODEL_RESPONSE_IDLE_MS = 30 * 1000
import { API_TIMEOUT_MS } from '@/api/constants'
import { hasControlCharacter, PLUGIN_MANAGEMENT_BRIDGE, PLUGIN_MANAGEMENT_BRIDGE_VERSION } from './bridge-contract'
export * from './bridge-contract'
const MAXIMUM_MANAGEMENT_RESOURCES = 128
const MAXIMUM_MANAGEMENT_RESOURCE_BYTES = 1024 * 1024
@@ -50,11 +44,6 @@ const JAVASCRIPT_CONTENT_TYPES = new Set([
'text/javascript',
])
export interface PluginFrameTheme {
name: 'light' | 'dark'
tokens: Record<string, string>
}
export interface AssembledPluginPage {
srcdoc: string
revoke: () => void
@@ -439,13 +428,6 @@ function safeThemeValue(value: string) {
return normalized
}
function hasControlCharacter(value: string) {
return [...value].some((character) => {
const code = character.codePointAt(0) ?? 0
return code < 0x20 || code === 0x7F
})
}
function createBridgeScript(input: {
view: PluginManagementView
page: PluginManagementPage
@@ -467,369 +449,8 @@ function createBridgeScript(input: {
theme: input.theme,
viewportHeight: input.viewportHeight,
resourceUrls: input.resourceUrls,
}).replace(/</gu, '\\u003c')
return `(() => {
'use strict';
const initial = ${initial};
const pending = new Map();
const modelOperations = new Map();
let sequence = 0;
let resizeObserver;
let resizeFrame = 0;
let previousHeight = 0;
let disposed = false;
let currentTheme = initial.theme.name;
const maximumBodyBytes = ${MAXIMUM_MANAGEMENT_BODY_BYTES};
const maximumQueryBytes = ${MAXIMUM_MANAGEMENT_QUERY_BYTES};
const maximumInFlight = ${MAXIMUM_MANAGEMENT_IN_FLIGHT};
const maximumModelBodyBytes = ${MAXIMUM_MODEL_BODY_BYTES};
const maximumModelChunkBytes = ${MAXIMUM_MODEL_CHUNK_BYTES};
const maximumModelInFlight = ${MAXIMUM_MODEL_IN_FLIGHT};
const modelRequestDeadlineMs = ${MODEL_REQUEST_DEADLINE_MS};
function applyLayout(height) {
if (Number.isSafeInteger(height) && height >= 0) {
document.documentElement.style.setProperty('--cp-plugin-viewport-height', height + 'px');
}
}
function applyTheme(theme) {
if (!theme || (theme.name !== 'light' && theme.name !== 'dark') || !theme.tokens || typeof theme.tokens !== 'object') return;
const root = document.documentElement;
root.dataset.theme = theme.name;
root.style.colorScheme = theme.name;
for (const [name, value] of Object.entries(theme.tokens)) {
if (typeof name === 'string' && name.startsWith('--cp-') && typeof value === 'string') root.style.setProperty(name, value);
}
currentTheme = theme.name;
window.dispatchEvent(new CustomEvent('codex-proxy-themechange', { detail: { theme: currentTheme } }));
}
async function bodyBytes(body) {
if (body == null) return new ArrayBuffer(0);
if (typeof body === 'string') return new TextEncoder().encode(body).buffer;
if (body instanceof ArrayBuffer) return body.slice(0);
if (ArrayBuffer.isView(body)) return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
throw new TypeError('请求正文只支持 string、ArrayBuffer 或 TypedArray');
}
function nextRequestId() {
sequence += 1;
if (!Number.isSafeInteger(sequence)) throw new Error('插件页面请求序号已耗尽');
return sequence;
}
function post(type, id, payload, transfer = []) {
parent.postMessage({
target: initial.target,
version: initial.version,
channel: initial.channel,
session: initial.session,
type,
id,
payload,
}, initial.parentOrigin, transfer);
}
function invoke(kind, payload, transfer = []) {
if (pending.size >= maximumInFlight) return Promise.reject(new Error('插件管理请求已达并发上限'));
const id = nextRequestId();
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
pending.delete(id);
reject(new Error('插件管理请求超时'));
}, kind === 'request' ? ${PLUGIN_MANAGEMENT_TIMEOUT_MS} : ${API_TIMEOUT_MS});
pending.set(id, { resolve, reject, timer });
post(kind, id, payload, transfer);
});
}
function reportContentHeight() {
// 不读 documentElement.scrollHeight,避免当前 iframe 高度阻止内容收缩。
const height = Math.max(1, Math.ceil(document.body.getBoundingClientRect().height));
if (height === previousHeight) return;
previousHeight = height;
post('resize', nextRequestId(), { height });
}
async function observeContentHeight() {
// load 后先触发布局,让实际使用的字体进入加载集合,再等待字体与布局就绪。
document.body.getBoundingClientRect();
await document.fonts.ready;
if (disposed) return;
// 首次上报不依赖渲染回调,后台或视口外的 iframe 也能完成初始化。
reportContentHeight();
resizeObserver = new ResizeObserver(() => {
if (resizeFrame) return;
resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0;
reportContentHeight();
});
});
resizeObserver.observe(document.body, { box: 'border-box' });
}
window.addEventListener('load', observeContentHeight, { once: true });
async function request(input) {
if (!input || typeof input !== 'object') throw new TypeError('插件管理请求无效');
const method = String(input.method || '').toUpperCase();
const path = String(input.path || '');
const query = input.query == null ? '' : String(input.query);
const contentType = input.contentType == null ? undefined : String(input.contentType);
if (new TextEncoder().encode(query).byteLength > maximumQueryBytes || query.includes('#') || query.startsWith('?')) throw new TypeError('插件管理查询参数无效');
const body = await bodyBytes(input.body);
if (body.byteLength > maximumBodyBytes) throw new TypeError('插件管理请求正文超过 1 MiB');
return invoke('request', { method, path, query, contentType, body }, [body]);
}
function callbackTicket(input) {
if (!input || typeof input !== 'object') return Promise.reject(new TypeError('插件回调票据请求无效'));
return invoke('callback-ticket', { path: String(input.path || ''), ttlSeconds: Number(input.ttlSeconds) });
}
function abortError(reason) {
return reason instanceof Error ? reason : new DOMException('模型请求已取消', 'AbortError');
}
function clearModelOperation(operation) {
if (modelOperations.get(operation.id) !== operation) return;
modelOperations.delete(operation.id);
clearTimeout(operation.timer);
if (operation.signal) operation.signal.removeEventListener('abort', operation.abort);
}
function settleModelPull(operation, error) {
const resolve = operation.pullResolve;
const reject = operation.pullReject;
operation.pullResolve = undefined;
operation.pullReject = undefined;
operation.pullPromise = undefined;
operation.pullId = undefined;
if (error) reject?.(error);
else resolve?.();
}
function failModelOperation(operation, error, notifyParent) {
if (modelOperations.get(operation.id) !== operation) return;
clearModelOperation(operation);
settleModelPull(operation, error);
if (operation.controller) {
try { operation.controller.error(error); } catch {}
} else {
operation.reject(error);
}
if (notifyParent) post('models-cancel', operation.id);
}
function finishModelOperation(operation) {
if (modelOperations.get(operation.id) !== operation) return;
clearModelOperation(operation);
settleModelPull(operation);
}
function validateResponseHeaders(value) {
if (!Array.isArray(value)) throw new Error('模型响应头无效');
for (const entry of value) {
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') throw new Error('模型响应头无效');
}
// 与宿主共享浏览器的 Headers 校验,不再按插件身份过滤字段。
return new Headers(value);
}
function handleModelMessage(message) {
const operation = modelOperations.get(message.id);
if (!operation) return;
if (message.type === 'models-error') {
const text = typeof message.error === 'string' && message.error ? message.error : '模型请求失败';
failModelOperation(operation, new Error(text), false);
return;
}
if (message.type === 'models-response') {
if (operation.controller || operation.responseStarted || !message.payload || typeof message.payload !== 'object') {
failModelOperation(operation, new Error('模型响应协议无效'), true);
return;
}
try {
const status = message.payload.status;
const statusText = message.payload.statusText == null ? '' : message.payload.statusText;
const hasBody = message.payload.hasBody;
if (!Number.isSafeInteger(status) || status < 200 || status > 599 || typeof statusText !== 'string' || statusText.length > 256 || /[\\u0000-\\u001F\\u007F]/u.test(statusText) || typeof hasBody !== 'boolean') throw new Error('模型响应状态无效');
const headers = validateResponseHeaders(message.payload.headers);
operation.responseStarted = true;
if (!hasBody) {
finishModelOperation(operation);
operation.resolve(new Response(null, { status, statusText, headers }));
return;
}
const stream = new ReadableStream({
start(controller) {
operation.controller = controller;
},
pull() {
if (modelOperations.get(operation.id) !== operation) return undefined;
if (operation.pullPromise) return operation.pullPromise;
operation.pullSequence += 1;
operation.pullId = operation.pullSequence;
operation.pullPromise = new Promise((resolve, reject) => {
operation.pullResolve = resolve;
operation.pullReject = reject;
post('models-pull', operation.id, { pullId: operation.pullId });
});
return operation.pullPromise;
},
cancel() {
if (modelOperations.get(operation.id) === operation) {
clearModelOperation(operation);
settleModelPull(operation);
post('models-cancel', operation.id);
}
},
}, { highWaterMark: 0 });
operation.resolve(new Response(stream, { status, statusText, headers }));
} catch (error) {
failModelOperation(operation, error instanceof Error ? error : new Error('模型响应协议无效'), true);
}
return;
}
if (!operation.controller || !operation.responseStarted) {
failModelOperation(operation, new Error('模型响应协议无效'), true);
return;
}
if (message.type === 'models-chunk') {
const pullId = message.payload?.pullId;
const body = message.payload?.body;
if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId || !(body instanceof ArrayBuffer) || body.byteLength === 0 || body.byteLength > maximumModelChunkBytes) {
failModelOperation(operation, new Error('模型响应分块无效'), true);
return;
}
try {
operation.controller.enqueue(new Uint8Array(body));
settleModelPull(operation);
} catch (error) {
failModelOperation(operation, error instanceof Error ? error : new Error('模型响应读取失败'), true);
}
return;
}
if (message.type === 'models-complete') {
const pullId = message.payload?.pullId;
if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId) {
failModelOperation(operation, new Error('模型响应结束标记无效'), true);
return;
}
try { operation.controller.close(); } catch {}
finishModelOperation(operation);
}
}
function modelResponses(input) {
if (!input || typeof input !== 'object' || !input.body || typeof input.body !== 'object' || Array.isArray(input.body)) return Promise.reject(new TypeError('Responses 请求正文必须是 JSON 对象'));
if (typeof input.clientKeyId !== 'string' || !input.clientKeyId || input.clientKeyId !== input.clientKeyId.trim() || input.clientKeyId.length > 128 || /[\\u0000-\\u001F\\u007F]/u.test(input.clientKeyId)) return Promise.reject(new TypeError('clientKeyId 无效'));
if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) return Promise.reject(new TypeError('signal 必须是 AbortSignal'));
if (input.signal?.aborted) return Promise.reject(abortError(input.signal.reason));
if (modelOperations.size >= maximumModelInFlight) return Promise.reject(new Error('模型请求已达并发上限'));
let body;
try {
body = JSON.stringify(input.body);
} catch {
return Promise.reject(new TypeError('Responses 请求正文必须可序列化为 JSON'));
}
if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > maximumModelBodyBytes) return Promise.reject(new TypeError('Responses 请求正文超过 8 MiB'));
const id = nextRequestId();
return new Promise((resolve, reject) => {
const operation = {
id,
resolve,
reject,
signal: input.signal,
abort: undefined,
timer: undefined,
controller: undefined,
responseStarted: false,
pullPromise: undefined,
pullResolve: undefined,
pullReject: undefined,
pullSequence: 0,
pullId: undefined,
};
operation.abort = () => failModelOperation(operation, abortError(input.signal?.reason), true);
operation.timer = window.setTimeout(() => failModelOperation(operation, new Error('模型请求超过 10 分钟'), true), modelRequestDeadlineMs);
modelOperations.set(id, operation);
input.signal?.addEventListener('abort', operation.abort, { once: true });
post('models-request', id, { clientKeyId: input.clientKeyId, body });
});
}
document.addEventListener('click', (event) => {
if (event.defaultPrevented || !(event.target instanceof Element)) return;
const submitter = event.target.closest('button, input');
if (!(submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement)) return;
const type = submitter.type.toLowerCase();
if (type !== 'submit' && type !== 'image') return;
const form = submitter.form;
if (!form) return;
event.preventDefault();
queueMicrotask(() => {
if (!form.isConnected || !submitter.isConnected) return;
if (!submitter.formNoValidate && !form.reportValidity()) return;
form.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter }));
});
});
window.addEventListener('message', (event) => {
if (event.source !== parent || event.origin !== initial.parentOrigin) return;
const message = event.data;
if (!message || message.target !== initial.target || message.version !== initial.version || message.channel !== initial.channel || message.session !== initial.session) return;
if (message.type === 'theme') {
applyTheme(message.payload);
return;
}
if (message.type === 'layout') {
applyLayout(message.payload?.viewportHeight);
return;
}
if (!Number.isSafeInteger(message.id) || message.id < 1) return;
if (message.type === 'models-response' || message.type === 'models-chunk' || message.type === 'models-complete' || message.type === 'models-error') {
handleModelMessage(message);
return;
}
if (message.type !== 'result') return;
const operation = pending.get(message.id);
if (!operation) return;
pending.delete(message.id);
clearTimeout(operation.timer);
if (message.ok) operation.resolve(message.payload);
else operation.reject(new Error(typeof message.error === 'string' ? message.error : '插件管理请求失败'));
});
window.addEventListener('pagehide', () => {
disposed = true;
resizeObserver?.disconnect();
cancelAnimationFrame(resizeFrame);
for (const operation of [...modelOperations.values()]) {
clearModelOperation(operation);
settleModelPull(operation);
post('models-cancel', operation.id);
}
}, { once: true });
const api = {
version: initial.version,
plugin: Object.freeze(initial.plugin),
page: Object.freeze(initial.page),
get theme() { return currentTheme; },
request,
callbackTicket,
models: Object.freeze({ responses: modelResponses }),
resourceUrl(path) {
const key = String(path);
if (!Object.prototype.hasOwnProperty.call(initial.resourceUrls, key)) throw new Error('资源未注册或不可作为页面子资源');
const value = initial.resourceUrls[key];
return value;
},
};
Object.defineProperty(window, 'codexProxyPlugin', { value: Object.freeze(api), configurable: false, writable: false });
applyTheme(initial.theme);
applyLayout(initial.viewportHeight);
})();`
managementTimeoutMs: PLUGIN_MANAGEMENT_TIMEOUT_MS,
apiTimeoutMs: API_TIMEOUT_MS,
} satisfies PluginBridgeInitial).replace(/</gu, '\\u003c')
return `${bridgeSource};CodexProxyPluginBridge(${initial});`
}
-46
View File
@@ -2,7 +2,6 @@ import type {
PluginArtifact,
PluginArtifactMetadata,
PluginInstance,
PluginInstanceRuntimeStatus,
PluginObserverEvent,
PluginSource,
PluginUpdateSource,
@@ -120,21 +119,6 @@ export function pluginCapabilityForContribution(
)?.[0]
}
export const PLUGIN_RUNTIME_STATUS_LABELS: Record<PluginInstanceRuntimeStatus, string> = {
disabled: '已停用',
awaiting_publication: '等待发布',
preparing: '准备中',
running: '已启用',
blocked: '发布阻塞',
preparation_failed: '准备失败',
faulted: '运行故障',
draining: '排空中',
}
export function pluginRuntimeStatusLabel(status: PluginInstanceRuntimeStatus) {
return PLUGIN_RUNTIME_STATUS_LABELS[status]
}
export function sourceLabel(source: PluginSource | PluginUpdateSource) {
switch (source.kind) {
case 'builtin':
@@ -172,33 +156,3 @@ export function artifactForInstance(instance: PluginInstance, artifacts: PluginA
export function cloneJsonValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
export function configurationForSchema(
schemaValue: Record<string, unknown>,
current: Record<string, unknown> = {},
excludedFields: string[] = [],
) {
const schema = schemaValue as JsonSchema
let result: Record<string, unknown> = cloneJsonValue(current)
for (const [name, property] of Object.entries(schema.properties ?? {})) {
if (!excludedFields.includes(name) && !Object.hasOwn(result, name) && property.default !== undefined) {
// 计算属性保留 __proto__ 等合法 JSON 字段,不触发普通对象的原型 setter
result = { ...result, [name]: cloneJsonValue(property.default) }
}
}
return result
}
export function pluginIdOptions(artifacts: PluginArtifact[]) {
const names = new Map<string, string>()
for (const artifact of artifacts)
names.set(artifact.metadata.pluginId, artifact.metadata.displayName)
return [...names].map(([value, name]) => ({ value, label: `${name} · ${value}` }))
}
export function uniquePluginArtifacts(artifacts: PluginArtifact[]) {
return [...artifacts].sort((left, right) => {
const byId = left.metadata.pluginId.localeCompare(right.metadata.pluginId)
return byId || right.metadata.version.localeCompare(left.metadata.version, undefined, { numeric: true })
})
}
+1 -9
View File
@@ -1,13 +1,5 @@
import type { OutboundProxyRecord, OutboundProxyTest } from '@/api'
import type { OutboundProxyRecord } from '@/api'
export function effectiveProxyLocation(proxy: OutboundProxyRecord) {
return proxy.autoLocation ? proxy.detectedLocation?.location : proxy.location
}
export function locationDetectionWarning(test: OutboundProxyTest | null | undefined) {
if (test?.location.status === 'conflict')
return 'IPv4 与 IPv6 的出口时区不一致,自动位置未生效'
if (test?.location.status === 'failed')
return test.location.message
return ''
}
+1 -1
View File
@@ -13,5 +13,5 @@
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "build/**/*.ts"]
}
+2 -1
View File
@@ -3,6 +3,7 @@ import CodexProxyUI from '@codex-proxy/ui/vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { pluginBridge } from './build/plugin-bridge.ts'
export default defineConfig(({ mode }) => {
const sourceUi = mode === 'source'
@@ -10,7 +11,7 @@ export default defineConfig(({ mode }) => {
return {
base: '/',
plugins: [vue(), tailwindcss(), CodexProxyUI({ source: sourceUi ? uiRoot : undefined })],
plugins: [vue(), tailwindcss(), CodexProxyUI({ source: sourceUi ? uiRoot : undefined }), pluginBridge()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},