mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(容量熔断的自适应并发下调与到期探测解冻)
|
||||
|
||||
@@ -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] : []
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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});`
|
||||
}
|
||||
|
||||
@@ -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,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 ''
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"noEmit": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "build/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -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)) },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user