mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-10 00:06:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d29ddfffc | ||
|
|
45903b684b |
@@ -189,7 +189,6 @@ pub struct AccountSummary {
|
||||
/// 账号启停写入命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -197,7 +196,6 @@ pub struct SetAccountEnabled {
|
||||
/// 账号批量删除命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccounts {
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,6 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
@@ -151,7 +150,6 @@ pub struct CreateClientKey {
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -175,7 +173,6 @@ impl fmt::Debug for NewClientKey {
|
||||
/// 修改 Client Key 的公开策略字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -186,7 +183,6 @@ pub struct UpdateClientKey {
|
||||
/// 修改 Client Key 启用状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -194,7 +190,6 @@ pub struct SetClientKeyEnabled {
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,6 @@ pub struct CredentialDetails {
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -110,7 +109,6 @@ impl fmt::Debug for ImportCredentials {
|
||||
formatter
|
||||
.debug_struct("ImportCredentials")
|
||||
.field("context", &self.context)
|
||||
.field("expected_config_revision", &self.expected_config_revision)
|
||||
.field("document", &self.document)
|
||||
.finish()
|
||||
}
|
||||
@@ -169,7 +167,6 @@ pub struct PreparedCredentialImport {
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
|
||||
@@ -254,7 +251,6 @@ pub enum AuthorizationMutationTarget {
|
||||
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingAuthorizationMutation {
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
@@ -263,24 +259,17 @@ pub struct PendingAuthorizationMutation {
|
||||
impl PendingAuthorizationMutation {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
expected_config_revision,
|
||||
provider_kind,
|
||||
target,
|
||||
owner_binding,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn expected_config_revision(&self) -> Revision {
|
||||
self.expected_config_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
@@ -301,7 +290,6 @@ impl PendingAuthorizationMutation {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ReauthorizationTarget>,
|
||||
}
|
||||
@@ -406,7 +394,6 @@ impl fmt::Debug for CompleteAuthorization {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutation {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
@@ -422,7 +409,6 @@ pub struct CredentialMutationResult {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletion {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
@@ -520,7 +506,6 @@ impl fmt::Debug for PreparedCredentialRotation {
|
||||
/// Admin 交给 Store 的轮换或 refresh 事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialRotationCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub prepared: PreparedCredentialRotationFacts,
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ pub struct RuntimeSettings {
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub expected_config_revision: Revision,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
|
||||
@@ -179,7 +179,7 @@ pub trait AuthStore: Send + Sync {
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 管理及其 revision CAS 写入。
|
||||
/// Client API Key 管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
@@ -265,7 +265,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 的 revision CAS 写入。
|
||||
/// Runtime settings 与管理员 API Key 写入。
|
||||
#[async_trait]
|
||||
pub trait SettingsStore: Send + Sync {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
|
||||
@@ -280,14 +280,12 @@ pub trait SettingsStore: Send + Sync {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
key: AdminApiKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use gateway_core::{
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
AdminError, MutationContext,
|
||||
accounts::{
|
||||
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
|
||||
AccountRecord, AccountStatus, AccountUsageWindowQuery,
|
||||
@@ -54,7 +54,6 @@ pub trait AccountsService: Send + Sync {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expected_config_revision: Revision,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError>;
|
||||
|
||||
@@ -354,7 +353,6 @@ impl AccountsService for DefaultAccountsService {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expected_config_revision: Revision,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError> {
|
||||
let (account, provider) = self.provider_for_account(&account_id).await?;
|
||||
@@ -367,7 +365,6 @@ impl AccountsService for DefaultAccountsService {
|
||||
validate_prepared_rotation(&account, &prepared, "provider credential refresh")?;
|
||||
let result = commit_credential_refresh(
|
||||
self.accounts.as_ref(),
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
context,
|
||||
"provider credential refresh",
|
||||
|
||||
@@ -94,7 +94,6 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.store
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
id,
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
|
||||
@@ -108,7 +108,6 @@ async fn pending_authorization(
|
||||
},
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
command.expected_config_revision,
|
||||
provider_kind.clone(),
|
||||
target,
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
@@ -215,20 +214,13 @@ async fn commit_authorization(
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_rotation(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -244,20 +236,13 @@ async fn commit_credential_rotation(
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_refresh(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -289,7 +274,6 @@ async fn set_credential_enabled(
|
||||
let revision = accounts
|
||||
.set_account_enabled(
|
||||
SetAccountEnabled {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
enabled,
|
||||
},
|
||||
@@ -320,7 +304,6 @@ async fn delete_credentials(
|
||||
let revision = accounts
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
account_ids: account_ids
|
||||
.iter()
|
||||
.map(|account_id| account_id.as_str().to_owned())
|
||||
|
||||
@@ -112,7 +112,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
@@ -127,13 +126,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
@@ -200,7 +193,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let expected_config_revision = command.mutation.expected_config_revision;
|
||||
let account_id = command.mutation.account_id;
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
@@ -227,7 +219,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
|
||||
@@ -86,13 +86,12 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<RegeneratedAdminApiKey, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let key = AdminApiKey::new(format!("admin-{}", hex::encode(bytes)));
|
||||
let mutation = self
|
||||
.store
|
||||
.replace_admin_api_key(settings.config_revision, key.clone(), context)
|
||||
.replace_admin_api_key(key.clone(), context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
@@ -103,10 +102,9 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<AdminApiKeyMutation, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
let mutation = self
|
||||
.store
|
||||
.delete_admin_api_key(settings.config_revision, context)
|
||||
.delete_admin_api_key(context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
|
||||
@@ -89,7 +89,6 @@ impl XaiService for DefaultXaiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
@@ -104,13 +103,7 @@ impl XaiService for DefaultXaiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
|
||||
@@ -342,7 +342,6 @@ pub(super) struct FakeAccountStore {
|
||||
account: AccountRecord,
|
||||
fail_commit: Mutex<bool>,
|
||||
audit_requests: Mutex<Vec<String>>,
|
||||
authorization_revision: Mutex<Option<Revision>>,
|
||||
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
|
||||
}
|
||||
|
||||
@@ -353,7 +352,6 @@ impl FakeAccountStore {
|
||||
account: account_record(kind),
|
||||
fail_commit: Mutex::new(false),
|
||||
audit_requests: Mutex::new(Vec::new()),
|
||||
authorization_revision: Mutex::new(None),
|
||||
quota_window_usage: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
@@ -366,13 +364,6 @@ impl FakeAccountStore {
|
||||
self.audit_requests.lock().expect("audit requests").clone()
|
||||
}
|
||||
|
||||
pub(super) fn authorization_revision(&self) -> Option<Revision> {
|
||||
*self
|
||||
.authorization_revision
|
||||
.lock()
|
||||
.expect("authorization revision")
|
||||
}
|
||||
|
||||
pub(super) fn set_quota_window_usage(&self, usage: Vec<AccountUsageWindowResult>) {
|
||||
*self.quota_window_usage.lock().expect("quota window usage") = usage;
|
||||
}
|
||||
@@ -519,10 +510,6 @@ impl AccountStore for FakeAccountStore {
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
self.record("store.commit_authorization");
|
||||
self.record_context(context);
|
||||
*self
|
||||
.authorization_revision
|
||||
.lock()
|
||||
.expect("authorization revision") = Some(command.pending.expected_config_revision());
|
||||
self.require_commit()?;
|
||||
let (account_id, credential_revision) = match command.credential {
|
||||
AuthorizationCredentialCommit::Create(credential) => (credential.account_id, None),
|
||||
@@ -627,7 +614,6 @@ impl SettingsStore for StaticSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -636,7 +622,6 @@ impl SettingsStore for StaticSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(store_unavailable())
|
||||
@@ -769,7 +754,6 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
|
||||
.accounts()
|
||||
.refresh(
|
||||
&context("refresh-request"),
|
||||
revision(1),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
@@ -882,7 +866,6 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
.accounts()
|
||||
.refresh(
|
||||
&context("refresh-provider-error"),
|
||||
revision(1),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
@@ -895,7 +878,7 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_refresh_store_failure_should_drop_guard_after_cas_attempt() {
|
||||
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
@@ -906,11 +889,10 @@ async fn accounts_refresh_store_failure_should_drop_guard_after_cas_attempt() {
|
||||
.accounts()
|
||||
.refresh(
|
||||
&context("refresh-store-error"),
|
||||
revision(1),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
.expect_err("Store CAS must fail");
|
||||
.expect_err("Store commit must fail");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
@@ -1039,7 +1021,7 @@ fn prepared_create_with_id(
|
||||
|
||||
fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResult {
|
||||
CredentialMutationResult {
|
||||
config_revision: revision(command.expected_config_revision.get() + 1),
|
||||
config_revision: revision(2),
|
||||
account_id: command.prepared.account_id,
|
||||
credential_revision: Some(revision(
|
||||
command.prepared.expected_credential_revision.get() + 1,
|
||||
|
||||
@@ -449,7 +449,6 @@ impl SettingsStore for UnavailableStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -458,7 +457,6 @@ impl SettingsStore for UnavailableStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unavailable("admin API key"))
|
||||
|
||||
@@ -518,7 +518,6 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -527,7 +526,6 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(super::unavailable("admin API key"))
|
||||
|
||||
@@ -114,7 +114,6 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -151,7 +150,6 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-batch"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -193,7 +191,6 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-quota-failure"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -218,7 +215,6 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-error"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -237,7 +233,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
context: context("oauth-start-openai"),
|
||||
expected_config_revision: revision(7),
|
||||
name: "reauthorize".to_owned(),
|
||||
reauthorization: Some(ReauthorizationTarget {
|
||||
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
@@ -248,7 +243,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
.expect("start reauthorization");
|
||||
|
||||
let pending = provider.pending().expect("pending envelope");
|
||||
assert_eq!(pending.expected_config_revision(), revision(7));
|
||||
assert_eq!(pending.provider_kind().as_str(), "openai");
|
||||
assert_eq!(
|
||||
pending.owner_binding().started_request_id(),
|
||||
@@ -284,7 +278,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
"guard.finish",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.authorization_revision(), Some(revision(7)));
|
||||
assert_eq!(store.audit_requests(), ["oauth-complete-openai"]);
|
||||
}
|
||||
|
||||
@@ -299,7 +292,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
}
|
||||
}
|
||||
@@ -307,7 +299,6 @@ fn mutation(account_id: &str) -> CredentialMutation {
|
||||
fn deletion(account_id: &str) -> CredentialDeletion {
|
||||
CredentialDeletion {
|
||||
context: context("request-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
AdminErrorKind, MutationContext,
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
@@ -33,7 +33,6 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -42,7 +41,6 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unused())
|
||||
@@ -63,7 +61,6 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
request_id: "request-settings".to_owned(),
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
expected_config_revision: Revision::new(1).expect("revision"),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 0,
|
||||
refresh_concurrency: 1,
|
||||
|
||||
@@ -8,9 +8,7 @@ use gateway_admin::{
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
use super::accounts::{
|
||||
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
|
||||
};
|
||||
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded};
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_disable_should_commit_then_notify_the_stateless_provider() {
|
||||
@@ -47,7 +45,6 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai"),
|
||||
expected_config_revision: revision(3),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -84,7 +81,6 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-batch"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -119,7 +115,6 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-quota-failure"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -140,7 +135,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-xai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,6 @@ fn parse_sort_direction(value: &str) -> Option<SortDirection> {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountPageData {
|
||||
pub config_revision: u64,
|
||||
pub items: Vec<AccountView>,
|
||||
pub page: PageMeta,
|
||||
pub summary: AccountSummaryView,
|
||||
@@ -391,31 +390,21 @@ impl AccountActionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 手工 OAuth 刷新会变更持久 credential,因此必须携带配置 CAS revision。
|
||||
/// 手工 OAuth 刷新会变更持久 credential。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountRefreshRequest {
|
||||
pub account_id: String,
|
||||
pub expected_config_revision: u64,
|
||||
}
|
||||
|
||||
impl AccountRefreshRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if self.expected_config_revision == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
Ok(())
|
||||
require_account_id(&self.account_id, "accountId")
|
||||
}
|
||||
|
||||
fn into_command(self) -> Result<(Revision, ProviderAccountId), WireValidationError> {
|
||||
fn into_command(self) -> Result<ProviderAccountId, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok((
|
||||
Revision::new(self.expected_config_revision)
|
||||
.map_err(|_| WireValidationError::new("expectedConfigRevision"))?,
|
||||
ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
))
|
||||
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +449,6 @@ pub struct AccountModelsData {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountRefreshData {
|
||||
pub config_revision: u64,
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
@@ -579,14 +567,12 @@ impl AccountProvider {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportRequest {
|
||||
pub provider: String,
|
||||
pub expected_config_revision: u64,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl AccountImportRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
|
||||
if !self.data.is_object()
|
||||
|| serde_json::to_vec(&self.data)
|
||||
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES)
|
||||
@@ -606,10 +592,6 @@ impl AccountImportRequest {
|
||||
provider,
|
||||
ImportCredentials {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
document: provider_document(self.data, "data")?,
|
||||
},
|
||||
))
|
||||
@@ -620,7 +602,6 @@ impl AccountImportRequest {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StartAccountAuthorizationRequest {
|
||||
pub provider: String,
|
||||
pub expected_config_revision: u64,
|
||||
pub name: String,
|
||||
pub account_id: Option<String>,
|
||||
pub expected_credential_revision: Option<u64>,
|
||||
@@ -629,7 +610,6 @@ pub struct StartAccountAuthorizationRequest {
|
||||
impl StartAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
|
||||
require_text(&self.name, MAX_NAME_BYTES, "name")?;
|
||||
match (
|
||||
self.account_id.as_deref(),
|
||||
@@ -663,10 +643,6 @@ impl StartAccountAuthorizationRequest {
|
||||
provider,
|
||||
StartAuthorization {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
name: self.name,
|
||||
reauthorization,
|
||||
},
|
||||
@@ -721,14 +697,12 @@ impl CompleteAccountAuthorizationRequest {
|
||||
pub struct AccountMutationRequest {
|
||||
pub provider: String,
|
||||
pub account_id: String,
|
||||
pub expected_config_revision: u64,
|
||||
}
|
||||
|
||||
impl AccountMutationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")
|
||||
require_account_id(&self.account_id, "accountId")
|
||||
}
|
||||
|
||||
fn into_command(
|
||||
@@ -741,10 +715,6 @@ impl AccountMutationRequest {
|
||||
provider,
|
||||
CredentialMutation {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
},
|
||||
@@ -757,7 +727,6 @@ impl AccountMutationRequest {
|
||||
pub struct AccountDeletionRequest {
|
||||
pub provider: String,
|
||||
pub account_ids: Vec<String>,
|
||||
pub expected_config_revision: u64,
|
||||
}
|
||||
|
||||
impl AccountDeletionRequest {
|
||||
@@ -773,7 +742,7 @@ impl AccountDeletionRequest {
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
}
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_command(
|
||||
@@ -786,10 +755,6 @@ impl AccountDeletionRequest {
|
||||
provider,
|
||||
CredentialDeletion {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
account_ids: self
|
||||
.account_ids
|
||||
.into_iter()
|
||||
@@ -806,7 +771,6 @@ impl AccountDeletionRequest {
|
||||
pub struct RotateAccountRequest {
|
||||
pub provider: String,
|
||||
pub account_id: String,
|
||||
pub expected_config_revision: u64,
|
||||
pub expected_credential_revision: u64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
@@ -818,7 +782,6 @@ impl RotateAccountRequest {
|
||||
return Err(WireValidationError::new("provider"));
|
||||
}
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
|
||||
require_positive(
|
||||
self.expected_credential_revision,
|
||||
"expectedCredentialRevision",
|
||||
@@ -840,10 +803,6 @@ impl RotateAccountRequest {
|
||||
Ok(RotateCredential {
|
||||
mutation: CredentialMutation {
|
||||
context,
|
||||
expected_config_revision: revision(
|
||||
self.expected_config_revision,
|
||||
"expectedConfigRevision",
|
||||
)?,
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
},
|
||||
@@ -859,7 +818,6 @@ impl RotateAccountRequest {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountImportData {
|
||||
pub config_revision: u64,
|
||||
pub imported_count: usize,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
@@ -872,7 +830,6 @@ impl From<CredentialImportResult> for AccountImportData {
|
||||
.map(|account_id| account_id.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
config_revision: result.config_revision.get(),
|
||||
imported_count: account_ids.len(),
|
||||
account_ids,
|
||||
}
|
||||
@@ -900,7 +857,6 @@ impl From<AuthorizationStarted> for AccountAuthorizationData {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountMutationData {
|
||||
pub config_revision: u64,
|
||||
pub account_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_revision: Option<u64>,
|
||||
@@ -909,7 +865,6 @@ pub struct AccountMutationData {
|
||||
impl From<CredentialMutationResult> for AccountMutationData {
|
||||
fn from(result: CredentialMutationResult) -> Self {
|
||||
Self {
|
||||
config_revision: result.config_revision.get(),
|
||||
account_id: result.account_id.to_string(),
|
||||
credential_revision: result.credential_revision.map(Revision::get),
|
||||
}
|
||||
@@ -919,7 +874,6 @@ impl From<CredentialMutationResult> for AccountMutationData {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountDeletionData {
|
||||
pub config_revision: u64,
|
||||
pub deleted_count: usize,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
@@ -932,7 +886,6 @@ impl From<CredentialDeletionResult> for AccountDeletionData {
|
||||
.map(|account_id| account_id.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
config_revision: result.config_revision.get(),
|
||||
deleted_count: account_ids.len(),
|
||||
account_ids,
|
||||
}
|
||||
@@ -1264,15 +1217,11 @@ async fn refresh_account<S>(
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (expected_config_revision, account_id) = request.into_command().map_err(map_wire_error)?;
|
||||
let account_id = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.refresh(
|
||||
&auth.context().mutation_context(),
|
||||
expected_config_revision,
|
||||
account_id,
|
||||
)
|
||||
.refresh(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_refresh_data(result, Utc::now());
|
||||
@@ -1394,7 +1343,6 @@ fn account_page_data(
|
||||
u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX)
|
||||
};
|
||||
AccountPageData {
|
||||
config_revision: result.config_revision.get(),
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -1412,7 +1360,6 @@ fn account_page_data(
|
||||
|
||||
fn account_refresh_data(result: AccountRefreshResult, now: DateTime<Utc>) -> AccountRefreshData {
|
||||
AccountRefreshData {
|
||||
config_revision: result.config_revision.get(),
|
||||
account: account_view(result.account, now),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
|
||||
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
|
||||
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
},
|
||||
use gateway_admin::model::client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
|
||||
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
|
||||
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
};
|
||||
use gateway_core::{
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
@@ -138,7 +135,6 @@ impl ClientKeySort {
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateClientKeyRequest {
|
||||
expected_config_revision: u64,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
@@ -149,14 +145,12 @@ pub struct CreateClientKeyRequest {
|
||||
impl CreateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(CreateClientKey {
|
||||
expected_config_revision: revision(self.expected_config_revision)?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
provider_kind: ProviderKind::new(self.provider_kind)
|
||||
@@ -174,7 +168,6 @@ impl CreateClientKeyRequest {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateClientKeyRequest {
|
||||
id: String,
|
||||
expected_config_revision: u64,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
@@ -186,14 +179,12 @@ impl UpdateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<UpdateClientKey, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(UpdateClientKey {
|
||||
expected_config_revision: revision(self.expected_config_revision)?,
|
||||
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
@@ -207,12 +198,11 @@ impl UpdateClientKeyRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 只携带 ID 与配置 revision 的 Client Key mutation 请求。
|
||||
/// 只携带 ID 的 Client Key mutation 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClientKeyRevisionRequest {
|
||||
pub struct ClientKeyMutationRequest {
|
||||
id: String,
|
||||
expected_config_revision: u64,
|
||||
}
|
||||
|
||||
/// 读取一次完整 Key 的 ID query。
|
||||
@@ -233,20 +223,15 @@ impl ClientKeyIdQuery {
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyRevisionRequest {
|
||||
/// 校验请求并取出 ID 与 revision。
|
||||
pub fn into_parts(self) -> Result<(String, u64), WireValidationError> {
|
||||
impl ClientKeyMutationRequest {
|
||||
/// 校验请求并取出 ID。
|
||||
pub fn into_id(self) -> Result<String, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
Ok((self.id, self.expected_config_revision))
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
fn into_command_parts(self) -> Result<(ClientApiKeyId, Revision), WireValidationError> {
|
||||
let (id, expected_config_revision) = self.into_parts()?;
|
||||
Ok((
|
||||
client_key_id(id, "clientKeyMutationNotFound")?,
|
||||
revision(expected_config_revision)?,
|
||||
))
|
||||
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
|
||||
client_key_id(self.into_id()?, "clientKeyMutationNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +274,6 @@ impl From<ClientKeyRecord> for ClientKeyView {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientKeyListData {
|
||||
config_revision: u64,
|
||||
items: Vec<ClientKeyView>,
|
||||
next_cursor: Option<String>,
|
||||
total: u64,
|
||||
@@ -298,14 +282,8 @@ pub struct ClientKeyListData {
|
||||
impl ClientKeyListData {
|
||||
/// 构造 Client Key 列表响应。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
config_revision: u64,
|
||||
items: Vec<ClientKeyView>,
|
||||
next_cursor: Option<String>,
|
||||
total: u64,
|
||||
) -> Self {
|
||||
pub fn new(items: Vec<ClientKeyView>, next_cursor: Option<String>, total: u64) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
items,
|
||||
next_cursor,
|
||||
total,
|
||||
@@ -325,7 +303,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
.map(encode_client_key_cursor)
|
||||
.transpose()?;
|
||||
Ok(Self::new(
|
||||
page.config_revision.get(),
|
||||
page.items.into_iter().map(Into::into).collect(),
|
||||
next_cursor,
|
||||
page.total,
|
||||
@@ -337,7 +314,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatedClientKeyData {
|
||||
config_revision: u64,
|
||||
id: String,
|
||||
prefix: String,
|
||||
plaintext_key: String,
|
||||
@@ -346,9 +322,8 @@ pub struct CreatedClientKeyData {
|
||||
impl CreatedClientKeyData {
|
||||
/// 构造一次性创建响应。
|
||||
#[must_use]
|
||||
pub fn new(config_revision: u64, id: String, prefix: String, plaintext_key: String) -> Self {
|
||||
pub fn new(id: String, prefix: String, plaintext_key: String) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
id,
|
||||
prefix,
|
||||
plaintext_key,
|
||||
@@ -359,7 +334,6 @@ impl CreatedClientKeyData {
|
||||
impl From<CreatedClientKey> for CreatedClientKeyData {
|
||||
fn from(created: CreatedClientKey) -> Self {
|
||||
Self::new(
|
||||
created.config_revision.get(),
|
||||
created.secret.record.id.to_string(),
|
||||
created.secret.record.prefix.clone(),
|
||||
created.secret.expose_for_response().to_owned(),
|
||||
@@ -371,7 +345,6 @@ impl fmt::Debug for CreatedClientKeyData {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CreatedClientKeyData")
|
||||
.field("config_revision", &self.config_revision)
|
||||
.field("id", &self.id)
|
||||
.field("prefix", &self.prefix)
|
||||
.field("plaintext_key", &"[REDACTED]")
|
||||
@@ -417,24 +390,20 @@ impl fmt::Debug for RevealedClientKeyData {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MutatedClientKeyData {
|
||||
config_revision: u64,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl MutatedClientKeyData {
|
||||
/// 构造 mutation 响应。
|
||||
#[must_use]
|
||||
pub fn new(config_revision: u64, id: String) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
id,
|
||||
}
|
||||
pub fn new(id: String) -> Self {
|
||||
Self { id }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientKeyMutation> for MutatedClientKeyData {
|
||||
fn from(mutation: ClientKeyMutation) -> Self {
|
||||
Self::new(mutation.config_revision.get(), mutation.id.to_string())
|
||||
Self::new(mutation.id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,18 +551,6 @@ fn wire_cursor(cursor: ClientKeyCursor) -> Result<ClientKeyCursorData, WireValid
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
fn validate_revision(value: u64) -> Result<(), WireValidationError> {
|
||||
if value == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revision(value: u64) -> Result<Revision, WireValidationError> {
|
||||
validate_revision(value)?;
|
||||
Revision::new(value).map_err(|_| WireValidationError::new("expectedConfigRevision"))
|
||||
}
|
||||
|
||||
fn client_key_id(
|
||||
value: String,
|
||||
field: &'static str,
|
||||
@@ -768,23 +725,19 @@ where
|
||||
async fn disable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.set_enabled(
|
||||
&auth.context().mutation_context(),
|
||||
SetClientKeyEnabled {
|
||||
expected_config_revision,
|
||||
id,
|
||||
enabled: false,
|
||||
},
|
||||
SetClientKeyEnabled { id, enabled: false },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -793,23 +746,19 @@ where
|
||||
async fn enable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.set_enabled(
|
||||
&auth.context().mutation_context(),
|
||||
SetClientKeyEnabled {
|
||||
expected_config_revision,
|
||||
id,
|
||||
enabled: true,
|
||||
},
|
||||
SetClientKeyEnabled { id, enabled: true },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -818,23 +767,17 @@ where
|
||||
async fn delete_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.delete(
|
||||
&auth.context().mutation_context(),
|
||||
DeleteClientKey {
|
||||
expected_config_revision,
|
||||
id,
|
||||
},
|
||||
)
|
||||
.delete(&auth.context().mutation_context(), DeleteClientKey { id })
|
||||
.await,
|
||||
)
|
||||
}
|
||||
@@ -854,9 +797,6 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
"cursor" => AdminError::bad_request("Invalid client key cursor"),
|
||||
"clientKeyRevealNotFound" => AdminError::not_found("Client API key was not found"),
|
||||
"clientKeyMutationNotFound" => AdminError::not_found("client API key was not found"),
|
||||
"expectedConfigRevision" => {
|
||||
AdminError::bad_request("expectedConfigRevision must be positive")
|
||||
}
|
||||
_ => AdminError::bad_request("Invalid client API key request"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,8 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
},
|
||||
use gateway_admin::model::settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
|
||||
};
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,7 +28,6 @@ pub type ModelMappings = BTreeMap<String, String>;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub config_revision: u64,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
@@ -49,7 +44,6 @@ pub struct RuntimeSettingsView {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateRuntimeSettingsRequest {
|
||||
pub expected_config_revision: u64,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
@@ -64,9 +58,6 @@ pub struct UpdateRuntimeSettingsRequest {
|
||||
impl UpdateRuntimeSettingsRequest {
|
||||
/// 校验公共运行参数。
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self.expected_config_revision == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
validate_model_mappings(&self.model_mappings)?;
|
||||
for (value, field) in [
|
||||
(self.refresh_margin_seconds, "refreshMarginSeconds"),
|
||||
@@ -93,8 +84,6 @@ impl UpdateRuntimeSettingsRequest {
|
||||
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(ReplaceRuntimeSettings {
|
||||
expected_config_revision: Revision::new(self.expected_config_revision)
|
||||
.map_err(|_| WireValidationError::new("settingsRevisionOverflow"))?,
|
||||
model_mappings: domain_model_mappings(self.model_mappings)?,
|
||||
refresh_margin_seconds: self.refresh_margin_seconds,
|
||||
refresh_concurrency: u32::try_from(self.refresh_concurrency)
|
||||
@@ -117,7 +106,6 @@ impl UpdateRuntimeSettingsRequest {
|
||||
impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self {
|
||||
config_revision: settings.config_revision.get(),
|
||||
model_mappings: wire_model_mappings(settings.model_mappings),
|
||||
refresh_margin_seconds: settings.refresh_margin_seconds,
|
||||
refresh_concurrency: u64::from(settings.refresh_concurrency),
|
||||
@@ -358,7 +346,6 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
|
||||
|
||||
fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
let message = match error.field() {
|
||||
"settingsRevisionOverflow" => "expectedConfigRevision must be positive".to_owned(),
|
||||
"settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(),
|
||||
"settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(),
|
||||
"settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(),
|
||||
|
||||
@@ -147,7 +147,7 @@ mod actions {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_actions_should_require_frozen_account_ids_and_revision() {
|
||||
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
|
||||
let id: AccountIdQuery =
|
||||
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
|
||||
assert!(id.validate().is_ok());
|
||||
@@ -155,13 +155,16 @@ mod actions {
|
||||
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
|
||||
assert_eq!(action.validate().unwrap_err().field(), "accountId");
|
||||
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1",
|
||||
"expectedConfigRevision": 0
|
||||
"accountId": "acct_1"
|
||||
}))
|
||||
.expect("decode refresh");
|
||||
assert_eq!(
|
||||
refresh.validate().unwrap_err().field(),
|
||||
"expectedConfigRevision"
|
||||
assert!(refresh.validate().is_ok());
|
||||
assert!(
|
||||
serde_json::from_value::<AccountRefreshRequest>(json!({
|
||||
"accountId": "acct_1",
|
||||
"expectedConfigRevision": 0
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +172,6 @@ mod actions {
|
||||
fn account_import_should_use_provider_and_opaque_data_fields() {
|
||||
let valid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": {
|
||||
"providerOwnedUnknownField": {"nested": [1, 2, 3]},
|
||||
"accounts": [{"credentials": {"access_token": "provider-validates-this"}}]
|
||||
@@ -180,7 +182,6 @@ mod actions {
|
||||
|
||||
let invalid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": []
|
||||
}))
|
||||
.expect("decode invalid account import");
|
||||
@@ -188,27 +189,32 @@ mod actions {
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"document": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_deletion_should_validate_one_provider_batch_and_emit_account_ids() {
|
||||
let request: AccountDeletionRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"accountIds": ["acct_1", "acct_2"],
|
||||
"expectedConfigRevision": 8
|
||||
"accountIds": ["acct_1", "acct_2"]
|
||||
}))
|
||||
.expect("decode account deletion");
|
||||
assert!(request.validate().is_ok());
|
||||
|
||||
let duplicate: AccountDeletionRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"accountIds": ["acct_1", "acct_1"],
|
||||
"expectedConfigRevision": 8
|
||||
"accountIds": ["acct_1", "acct_1"]
|
||||
}))
|
||||
.expect("decode duplicate account deletion");
|
||||
assert_eq!(duplicate.validate().unwrap_err().field(), "accountIds");
|
||||
@@ -223,7 +229,6 @@ mod actions {
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize account deletion"),
|
||||
json!({
|
||||
"configRevision": 9,
|
||||
"deletedCount": 2,
|
||||
"accountIds": ["acct_1", "acct_2"]
|
||||
})
|
||||
@@ -239,7 +244,6 @@ mod actions {
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize account import"),
|
||||
json!({
|
||||
"configRevision": 8,
|
||||
"importedCount": 1,
|
||||
"accountIds": ["acct_imported"]
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use gateway_api::admin::client_keys::{
|
||||
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyRevisionRequest,
|
||||
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyMutationRequest,
|
||||
ClientKeySort, ClientKeySortDirection, ClientKeySortField, ClientKeyView,
|
||||
CreateClientKeyRequest, CreatedClientKeyData, ListClientKeysQuery, MutatedClientKeyData,
|
||||
RevealedClientKeyData, UpdateClientKeyRequest, decode_client_key_cursor,
|
||||
@@ -101,9 +101,8 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields() {
|
||||
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
|
||||
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"name": "terminal key",
|
||||
"label": "production",
|
||||
"providerKind": "openai",
|
||||
@@ -113,22 +112,11 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
.expect("deserialize create")
|
||||
.into_command()
|
||||
.expect("validate create");
|
||||
assert_eq!(valid.expected_config_revision.get(), 7);
|
||||
assert_eq!(valid.name, "terminal key");
|
||||
|
||||
for (payload, field) in [
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 0,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0
|
||||
}),
|
||||
"expectedConfigRevision",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 1,
|
||||
"name": " ",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 0,
|
||||
@@ -138,7 +126,6 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": u64::MAX,
|
||||
@@ -170,14 +157,23 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let revision = serde_json::from_value::<ClientKeyRevisionRequest>(json!({
|
||||
"id": "key_1",
|
||||
"expectedConfigRevision": 3
|
||||
assert!(
|
||||
serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"name": "terminal key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 2,
|
||||
"requestsPerMinute": 60
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let mutation_id = serde_json::from_value::<ClientKeyMutationRequest>(json!({
|
||||
"id": "key_1"
|
||||
}))
|
||||
.expect("deserialize revision mutation")
|
||||
.into_parts()
|
||||
.expect("validate revision mutation");
|
||||
assert_eq!(revision, ("key_1".to_owned(), 3));
|
||||
.expect("deserialize mutation")
|
||||
.into_id()
|
||||
.expect("validate mutation");
|
||||
assert_eq!(mutation_id, "key_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -225,9 +221,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
updated_at: created_at,
|
||||
last_used_at: Some(created_at),
|
||||
});
|
||||
let list = serde_json::to_value(ClientKeyListData::new(9, vec![view], None, 1))
|
||||
.expect("serialize list");
|
||||
assert_eq!(list["configRevision"], 9);
|
||||
let list =
|
||||
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
|
||||
assert!(list.get("configRevision").is_none());
|
||||
assert_eq!(list["total"], 1);
|
||||
assert_eq!(list["items"][0]["id"], "key_visible");
|
||||
assert_eq!(list["items"][0]["providerKind"], "openai");
|
||||
@@ -248,7 +244,6 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
|
||||
let plaintext = format!("sk_{}", "a".repeat(43));
|
||||
let created = CreatedClientKeyData::new(
|
||||
10,
|
||||
"key_created".to_owned(),
|
||||
"sk_aaaaaaaaa".to_owned(),
|
||||
plaintext.clone(),
|
||||
@@ -263,9 +258,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
json!({ "id": "key_created", "plaintextKey": plaintext })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MutatedClientKeyData::new(11, "key_created".to_owned()))
|
||||
serde_json::to_value(MutatedClientKeyData::new("key_created".to_owned()))
|
||||
.expect("serialize mutation"),
|
||||
json!({ "configRevision": 11, "id": "key_created" })
|
||||
json!({ "id": "key_created" })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -279,8 +279,9 @@ impl SettingsStore for MemorySettingsStore {
|
||||
command: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
let updated = RuntimeSettings {
|
||||
config_revision: next_revision(command.expected_config_revision),
|
||||
config_revision: next_revision(settings.config_revision),
|
||||
model_mappings: command.model_mappings,
|
||||
refresh_margin_seconds: command.refresh_margin_seconds,
|
||||
refresh_concurrency: command.refresh_concurrency,
|
||||
@@ -292,31 +293,31 @@ impl SettingsStore for MemorySettingsStore {
|
||||
audit_retention_days: command.audit_retention_days,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
*self.settings.lock().expect("settings") = updated.clone();
|
||||
*settings = updated.clone();
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
key: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
*self.api_key.lock().expect("API key") = Some(key);
|
||||
let revision = self.settings.lock().expect("settings").config_revision;
|
||||
Ok(AdminApiKeyMutation {
|
||||
config_revision: next_revision(expected_config_revision),
|
||||
config_revision: next_revision(revision),
|
||||
exists: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
*self.api_key.lock().expect("API key") = None;
|
||||
let revision = self.settings.lock().expect("settings").config_revision;
|
||||
Ok(AdminApiKeyMutation {
|
||||
config_revision: next_revision(expected_config_revision),
|
||||
config_revision: next_revision(revision),
|
||||
exists: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ async fn response_json(response: axum::response::Response) -> Value {
|
||||
|
||||
fn update_body() -> Value {
|
||||
json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"modelMappings": {
|
||||
"gpt-5.4": "gpt-5.5",
|
||||
"grok-latest": "grok-4.5"
|
||||
@@ -64,22 +63,11 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_require_positive_revision() {
|
||||
fn settings_request_should_reject_unknown_revision_field() {
|
||||
let mut body = update_body();
|
||||
body["expectedConfigRevision"] = json!(0);
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(body).expect("decode settings");
|
||||
assert_eq!(
|
||||
request.validate().unwrap_err().field(),
|
||||
"expectedConfigRevision"
|
||||
);
|
||||
body["expectedConfigRevision"] = json!(7);
|
||||
|
||||
let mut missing = update_body();
|
||||
missing
|
||||
.as_object_mut()
|
||||
.expect("settings object")
|
||||
.remove("expectedConfigRevision");
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(missing).is_err());
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,7 +112,7 @@ async fn settings_post_should_replace_global_model_mappings() {
|
||||
.expect("settings update response");
|
||||
let data = response_json(response).await["data"].clone();
|
||||
|
||||
assert_eq!(data["configRevision"], 8);
|
||||
assert!(data.get("configRevision").is_none());
|
||||
assert_eq!(data["modelMappings"]["gpt-5.4"], "gpt-5.5");
|
||||
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
|
||||
}
|
||||
|
||||
@@ -582,7 +582,6 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
};
|
||||
let snapshot = postgres::ControlPlaneRepository::replace_control_plane(
|
||||
&self.control_plane,
|
||||
store_revision(command.expected_config_revision)?,
|
||||
replacement,
|
||||
)
|
||||
.await
|
||||
@@ -592,32 +591,24 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
key: AdminApiKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
self.replace_admin_api_key_value(
|
||||
expected_config_revision,
|
||||
Some(key.expose_for_auth().to_owned()),
|
||||
context,
|
||||
)
|
||||
.await
|
||||
self.replace_admin_api_key_value(Some(key.expose_for_auth().to_owned()), context)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
self.replace_admin_api_key_value(expected_config_revision, None, context)
|
||||
.await
|
||||
self.replace_admin_api_key_value(None, context).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminSettingsStoreAdapter {
|
||||
async fn replace_admin_api_key_value(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
admin_api_key: Option<String>,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -639,7 +630,6 @@ impl AdminSettingsStoreAdapter {
|
||||
};
|
||||
let snapshot = postgres::ControlPlaneRepository::replace_control_plane(
|
||||
&self.control_plane,
|
||||
store_revision(expected_config_revision)?,
|
||||
postgres::ControlPlaneReplacement {
|
||||
settings: update,
|
||||
audit: mutation_audit(
|
||||
|
||||
@@ -36,7 +36,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder, Transaction};
|
||||
|
||||
use crate::{
|
||||
StoreError, StoreResult, admin_revision, admin_store_error, mutation_audit,
|
||||
postgres_unavailable, require_nonempty, store_revision,
|
||||
postgres_unavailable, require_nonempty,
|
||||
};
|
||||
|
||||
use super::{ControlPlaneRepository, PgControlPlaneRepository};
|
||||
@@ -633,7 +633,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
let revision = self
|
||||
.control_plane
|
||||
.create_client_api_key(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
NewClientApiKey {
|
||||
id: id.as_str().to_owned(),
|
||||
name: command.name,
|
||||
@@ -676,7 +675,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
let revision = self
|
||||
.control_plane
|
||||
.update_client_api_key(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
UpdateClientApiKeyDetails {
|
||||
id: id.as_str().to_owned(),
|
||||
name: command.name,
|
||||
@@ -716,7 +714,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
let revision = self
|
||||
.control_plane
|
||||
.set_client_api_key_enabled(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
id.as_str(),
|
||||
command.enabled,
|
||||
mutation_audit(
|
||||
@@ -739,7 +736,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
self.control_plane
|
||||
.delete_client_api_key(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
command.id.as_str(),
|
||||
mutation_audit(
|
||||
context,
|
||||
|
||||
@@ -74,27 +74,23 @@ pub trait ControlPlaneRepository: Send + Sync {
|
||||
|
||||
async fn replace_control_plane(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
replacement: ControlPlaneReplacement,
|
||||
) -> StoreResult<ControlPlaneSnapshot>;
|
||||
|
||||
async fn create_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: NewClientApiKey,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision>;
|
||||
|
||||
async fn update_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: UpdateClientApiKeyDetails,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision>;
|
||||
|
||||
async fn set_client_api_key_enabled(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
audit: AdminAuditEvent,
|
||||
@@ -102,7 +98,6 @@ pub trait ControlPlaneRepository: Send + Sync {
|
||||
|
||||
async fn delete_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision>;
|
||||
@@ -142,7 +137,6 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
|
||||
async fn replace_control_plane(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
replacement: ControlPlaneReplacement,
|
||||
) -> StoreResult<ControlPlaneSnapshot> {
|
||||
replacement.settings.validate()?;
|
||||
@@ -152,12 +146,9 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin control plane replacement"))?;
|
||||
let result = async {
|
||||
let revision = update_runtime_settings_in_transaction(
|
||||
&mut transaction,
|
||||
expected_revision,
|
||||
&replacement.settings,
|
||||
)
|
||||
.await?;
|
||||
let revision =
|
||||
update_runtime_settings_in_transaction(&mut transaction, &replacement.settings)
|
||||
.await?;
|
||||
append_admin_audit_event_in_transaction(&mut transaction, replacement.audit, revision)
|
||||
.await?;
|
||||
load_control_plane_in_transaction(&mut transaction).await
|
||||
@@ -183,41 +174,29 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
|
||||
async fn create_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: NewClientApiKey,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::CreateClientApiKey(key),
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
self.apply_targeted_mutation(ControlPlaneMutation::CreateClientApiKey(key), audit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
key: UpdateClientApiKeyDetails,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::UpdateClientApiKey(key),
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
self.apply_targeted_mutation(ControlPlaneMutation::UpdateClientApiKey(key), audit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_client_api_key_enabled(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::SetClientApiKeyEnabled {
|
||||
id: id.to_owned(),
|
||||
enabled,
|
||||
@@ -229,12 +208,10 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
|
||||
|
||||
async fn delete_client_api_key(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
id: &str,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
self.apply_targeted_mutation(
|
||||
expected_revision,
|
||||
ControlPlaneMutation::DeleteClientApiKey(id.to_owned()),
|
||||
audit,
|
||||
)
|
||||
@@ -252,7 +229,6 @@ enum ControlPlaneMutation {
|
||||
impl PgControlPlaneRepository {
|
||||
async fn apply_targeted_mutation(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
mutation: ControlPlaneMutation,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
@@ -262,8 +238,7 @@ impl PgControlPlaneRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin targeted control plane mutation"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_revision).await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
match mutation {
|
||||
ControlPlaneMutation::CreateClientApiKey(key) => {
|
||||
insert_client_api_key_in_transaction(&mut transaction, &key).await?;
|
||||
|
||||
@@ -485,25 +485,21 @@ pub trait ProviderAccountAdminRepository: Send + Sync {
|
||||
|
||||
async fn import_provider_accounts(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: ImportProviderAccounts,
|
||||
) -> StoreResult<ProviderAccountAdminImport>;
|
||||
|
||||
async fn rotate_provider_account(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: RotateProviderAccount,
|
||||
) -> StoreResult<ProviderAccountAdminRotation>;
|
||||
|
||||
async fn set_provider_account_enabled_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: SetProviderAccountEnabled,
|
||||
) -> StoreResult<Revision>;
|
||||
|
||||
async fn delete_provider_accounts_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: DeleteProviderAccounts,
|
||||
) -> StoreResult<Revision>;
|
||||
}
|
||||
@@ -767,7 +763,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn import_provider_accounts(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: ImportProviderAccounts,
|
||||
) -> StoreResult<ProviderAccountAdminImport> {
|
||||
command.validate()?;
|
||||
@@ -777,9 +772,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin import"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
let mut account_ids = Vec::with_capacity(command.accounts.len());
|
||||
for account in &command.accounts {
|
||||
account_ids
|
||||
@@ -798,7 +791,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn rotate_provider_account(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: RotateProviderAccount,
|
||||
) -> StoreResult<ProviderAccountAdminRotation> {
|
||||
command.scope.validate()?;
|
||||
@@ -814,9 +806,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin rotation"))?;
|
||||
let result = async {
|
||||
let config_revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let config_revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
let credential_revision = rotate_provider_account_in_transaction(
|
||||
&mut transaction,
|
||||
&command.scope,
|
||||
@@ -841,7 +831,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn set_provider_account_enabled_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: SetProviderAccountEnabled,
|
||||
) -> StoreResult<Revision> {
|
||||
command.scope.validate()?;
|
||||
@@ -852,9 +841,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin state change"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
set_provider_account_enabled_in_transaction(
|
||||
&mut transaction,
|
||||
&command.scope,
|
||||
@@ -872,7 +859,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
|
||||
async fn delete_provider_accounts_admin(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
command: DeleteProviderAccounts,
|
||||
) -> StoreResult<Revision> {
|
||||
command.scope.validate()?;
|
||||
@@ -883,9 +869,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin deletion"))?;
|
||||
let result = async {
|
||||
let revision =
|
||||
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
|
||||
.await?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
delete_provider_accounts_in_transaction(
|
||||
&mut transaction,
|
||||
&command.scope,
|
||||
@@ -1032,7 +1016,6 @@ impl PgAdminAccountStore {
|
||||
|
||||
async fn commit_prepared_import(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
prepared: PreparedCredentialImport,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
@@ -1046,22 +1029,19 @@ impl PgAdminAccountStore {
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
let imported = self
|
||||
.accounts
|
||||
.import_provider_accounts(
|
||||
store_revision(expected_config_revision)?,
|
||||
ImportProviderAccounts {
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: provider_kind.clone(),
|
||||
},
|
||||
accounts,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&provider_kind,
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: provider_kind.clone(),
|
||||
},
|
||||
)
|
||||
accounts,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&provider_kind,
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
Ok(CredentialImportResult {
|
||||
@@ -1083,7 +1063,6 @@ impl PgAdminAccountStore {
|
||||
|
||||
async fn commit_prepared_rotation(
|
||||
&self,
|
||||
expected_config_revision: AdminRevision,
|
||||
prepared: PreparedCredentialRotationFacts,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
@@ -1094,36 +1073,31 @@ impl PgAdminAccountStore {
|
||||
};
|
||||
let rotation = self
|
||||
.accounts
|
||||
.rotate_provider_account(
|
||||
store_revision(expected_config_revision)?,
|
||||
RotateProviderAccount {
|
||||
scope,
|
||||
profile: UpdateProviderAccount {
|
||||
id: account_id.as_str().to_owned(),
|
||||
name: prepared.name,
|
||||
email: prepared.email,
|
||||
plan_type: prepared.plan_type,
|
||||
},
|
||||
credential: ProviderCredentialUpdate {
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
expected_revision: store_revision(prepared.expected_credential_revision)?,
|
||||
provider_credentials_json: provider_document_json(
|
||||
prepared.provider_material,
|
||||
)
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?,
|
||||
has_refresh_token: prepared.has_refresh_token,
|
||||
access_token_expires_at: prepared.access_token_expires_at,
|
||||
next_refresh_at: prepared.next_refresh_at,
|
||||
},
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
account_id.as_str(),
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
scope,
|
||||
profile: UpdateProviderAccount {
|
||||
id: account_id.as_str().to_owned(),
|
||||
name: prepared.name,
|
||||
email: prepared.email,
|
||||
plan_type: prepared.plan_type,
|
||||
},
|
||||
)
|
||||
credential: ProviderCredentialUpdate {
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
expected_revision: store_revision(prepared.expected_credential_revision)?,
|
||||
provider_credentials_json: provider_document_json(prepared.provider_material)
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?,
|
||||
has_refresh_token: prepared.has_refresh_token,
|
||||
access_token_expires_at: prepared.access_token_expires_at,
|
||||
next_refresh_at: prepared.next_refresh_at,
|
||||
},
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
account_id.as_str(),
|
||||
vec!["credentials".to_owned()],
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
Ok(CredentialMutationResult {
|
||||
@@ -1383,13 +1357,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: CredentialImportCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialImportResult> {
|
||||
self.commit_prepared_import(
|
||||
command.expected_config_revision,
|
||||
command.prepared,
|
||||
context,
|
||||
"import_document",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_import(command.prepared, context, "import_document")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
@@ -1397,13 +1366,11 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: AuthorizationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
let expected_config_revision = command.pending.expected_config_revision();
|
||||
match command.credential {
|
||||
AuthorizationCredentialCommit::Create(credential) => {
|
||||
let account_id = credential.account_id.clone();
|
||||
let result = self
|
||||
.commit_prepared_import(
|
||||
expected_config_revision,
|
||||
PreparedCredentialImport {
|
||||
provider_kind: credential.provider_kind.clone(),
|
||||
credentials: vec![credential],
|
||||
@@ -1431,13 +1398,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
})
|
||||
}
|
||||
AuthorizationCredentialCommit::Reauthorize(prepared) => {
|
||||
self.commit_prepared_rotation(
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
context,
|
||||
"reauthorize",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_rotation(prepared, context, "reauthorize")
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1447,13 +1409,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: CredentialRotationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
self.commit_prepared_rotation(
|
||||
command.expected_config_revision,
|
||||
command.prepared,
|
||||
context,
|
||||
"rotate_credential",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_rotation(command.prepared, context, "rotate_credential")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
@@ -1461,13 +1418,8 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command: CredentialRotationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
self.commit_prepared_rotation(
|
||||
command.expected_config_revision,
|
||||
command.prepared,
|
||||
context,
|
||||
"refresh_credential",
|
||||
)
|
||||
.await
|
||||
self.commit_prepared_rotation(command.prepared, context, "refresh_credential")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_account_enabled(
|
||||
@@ -1478,21 +1430,18 @@ impl AccountStore for PgAdminAccountStore {
|
||||
let scope = self.required_scope(&command.account_id).await?;
|
||||
let action = if command.enabled { "enable" } else { "disable" };
|
||||
self.accounts
|
||||
.set_provider_account_enabled_admin(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
SetProviderAccountEnabled {
|
||||
scope,
|
||||
account_id: command.account_id.clone(),
|
||||
enabled: command.enabled,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&command.account_id,
|
||||
vec!["enabled".to_owned()],
|
||||
),
|
||||
},
|
||||
)
|
||||
.set_provider_account_enabled_admin(SetProviderAccountEnabled {
|
||||
scope,
|
||||
account_id: command.account_id.clone(),
|
||||
enabled: command.enabled,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"provider_account",
|
||||
&command.account_id,
|
||||
vec!["enabled".to_owned()],
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))
|
||||
.and_then(admin_revision)
|
||||
@@ -1517,20 +1466,17 @@ impl AccountStore for PgAdminAccountStore {
|
||||
"provider_accounts".to_owned()
|
||||
};
|
||||
self.accounts
|
||||
.delete_provider_accounts_admin(
|
||||
store_revision(command.expected_config_revision)?,
|
||||
DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: command.account_ids,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
"delete",
|
||||
"provider_account",
|
||||
&audit_target,
|
||||
Vec::new(),
|
||||
),
|
||||
},
|
||||
)
|
||||
.delete_provider_accounts_admin(DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: command.account_ids,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
"delete",
|
||||
"provider_account",
|
||||
&audit_target,
|
||||
Vec::new(),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| admin_store_error(ENTITY, error))
|
||||
.and_then(admin_revision)
|
||||
|
||||
@@ -12,7 +12,7 @@ use gateway_core::provider_ports::{
|
||||
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind,
|
||||
};
|
||||
|
||||
use crate::{ConflictKind, Revision, StoreError, StoreResult, postgres_unavailable};
|
||||
use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
@@ -111,11 +111,8 @@ impl RuntimeSettingsUpdate {
|
||||
pub trait RuntimeSettingsRepository: Send + Sync {
|
||||
async fn load_runtime_settings(&self) -> StoreResult<RuntimeSettings>;
|
||||
|
||||
async fn update_runtime_settings(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
update: RuntimeSettingsUpdate,
|
||||
) -> StoreResult<Revision>;
|
||||
async fn update_runtime_settings(&self, update: RuntimeSettingsUpdate)
|
||||
-> StoreResult<Revision>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -169,7 +166,6 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
|
||||
|
||||
async fn update_runtime_settings(
|
||||
&self,
|
||||
expected_revision: Revision,
|
||||
update: RuntimeSettingsUpdate,
|
||||
) -> StoreResult<Revision> {
|
||||
let mut transaction = self
|
||||
@@ -177,9 +173,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin runtime settings update"))?;
|
||||
let revision =
|
||||
update_runtime_settings_in_transaction(&mut transaction, expected_revision, &update)
|
||||
.await?;
|
||||
let revision = update_runtime_settings_in_transaction(&mut transaction, &update).await?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
@@ -228,28 +222,26 @@ pub(crate) async fn load_runtime_settings_in_transaction(
|
||||
|
||||
pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
expected_revision: Revision,
|
||||
update: &RuntimeSettingsUpdate,
|
||||
) -> StoreResult<Revision> {
|
||||
update.validate()?;
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update runtime_settings
|
||||
set config_revision = config_revision + 1,
|
||||
admin_api_key = $2,
|
||||
refresh_margin_seconds = $3,
|
||||
refresh_concurrency = $4,
|
||||
max_concurrent_per_account = $5,
|
||||
request_interval_ms = $6,
|
||||
rotation_strategy = $7,
|
||||
model_mappings_json = $8,
|
||||
usage_retention_days = $9,
|
||||
ops_event_retention_days = $10,
|
||||
audit_retention_days = $11,
|
||||
updated_at = now()
|
||||
where id = 1 and config_revision = $1
|
||||
returning config_revision",
|
||||
admin_api_key = $1,
|
||||
refresh_margin_seconds = $2,
|
||||
refresh_concurrency = $3,
|
||||
max_concurrent_per_account = $4,
|
||||
request_interval_ms = $5,
|
||||
rotation_strategy = $6,
|
||||
model_mappings_json = $7,
|
||||
usage_retention_days = $8,
|
||||
ops_event_retention_days = $9,
|
||||
audit_retention_days = $10,
|
||||
updated_at = now()
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
)
|
||||
.bind(i64::try_from(expected_revision.get()).map_err(|_| invalid_numeric())?)
|
||||
.bind(update.admin_api_key.as_deref())
|
||||
.bind(i64::try_from(update.refresh_margin_seconds).map_err(|_| invalid_numeric())?)
|
||||
.bind(i64::from(update.refresh_concurrency))
|
||||
@@ -263,32 +255,28 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
|
||||
.ok_or_else(|| StoreError::Conflict {
|
||||
.ok_or_else(|| StoreError::NotFound {
|
||||
entity: "runtime settings",
|
||||
id: "1".to_owned(),
|
||||
kind: ConflictKind::StaleRevision,
|
||||
})?;
|
||||
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
|
||||
}
|
||||
|
||||
pub(crate) async fn bump_config_revision_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
expected_revision: Revision,
|
||||
) -> StoreResult<Revision> {
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update runtime_settings
|
||||
set config_revision = config_revision + 1, updated_at = now()
|
||||
where id = 1 and config_revision = $1
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
)
|
||||
.bind(i64::try_from(expected_revision.get()).map_err(|_| invalid_numeric())?)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("bump config revision in transaction"))?
|
||||
.ok_or_else(|| StoreError::Conflict {
|
||||
.ok_or_else(|| StoreError::NotFound {
|
||||
entity: "runtime settings",
|
||||
id: "1".to_owned(),
|
||||
kind: ConflictKind::StaleRevision,
|
||||
})?;
|
||||
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::time::{Duration, SystemTime};
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationActor, MutationContext, PageSize, Revision as AdminRevision,
|
||||
MutationActor, MutationContext, PageSize,
|
||||
accounts::{
|
||||
AccountListQuery, AccountSort, AccountSortField, AccountStatus,
|
||||
AccountUsageWindowQuery, DeleteAccounts, SetAccountEnabled as AdminSetAccountEnabled,
|
||||
@@ -14,7 +14,7 @@ use gateway_admin::{
|
||||
CredentialAvailabilityFilter, CredentialListQuery, CredentialListWindow,
|
||||
},
|
||||
},
|
||||
ports::store::{AccountStore, AdminStoreErrorKind},
|
||||
ports::store::AccountStore,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, OpaqueProviderData,
|
||||
@@ -525,7 +525,6 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
|
||||
let revision = store
|
||||
.set_account_enabled(
|
||||
AdminSetAccountEnabled {
|
||||
expected_config_revision: AdminRevision::new(1).expect("initial revision"),
|
||||
account_id: "acct_terminal_mutation".to_owned(),
|
||||
enabled: false,
|
||||
},
|
||||
@@ -542,32 +541,9 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
|
||||
.expect("read disabled state");
|
||||
assert!(!enabled);
|
||||
|
||||
let error = store
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: AdminRevision::new(1).expect("stale revision"),
|
||||
account_ids: vec!["acct_terminal_mutation".to_owned()],
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect_err("stale delete must roll back");
|
||||
assert_eq!(error.kind(), AdminStoreErrorKind::StaleRevision);
|
||||
assert_eq!(current_revision(&database.pool).await, 2);
|
||||
assert_eq!(
|
||||
account_count(&database.pool, "acct_terminal_mutation").await,
|
||||
1
|
||||
);
|
||||
let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("count audit rows after rollback");
|
||||
assert_eq!(audit_count, 1);
|
||||
|
||||
let revision = store
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: revision,
|
||||
account_ids: vec!["acct_terminal_mutation".to_owned()],
|
||||
},
|
||||
&context,
|
||||
@@ -616,7 +592,6 @@ async fn terminal_admin_delete_removes_enabled_accounts_in_one_transaction() {
|
||||
let revision = PgAdminAccountStore::new(database.pool.clone())
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: AdminRevision::new(1).expect("initial revision"),
|
||||
account_ids: vec![
|
||||
"acct_enabled_delete_a".to_owned(),
|
||||
"acct_enabled_delete_b".to_owned(),
|
||||
@@ -649,18 +624,14 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
provider_kind: "openai".to_owned(),
|
||||
};
|
||||
let imported = repository
|
||||
.import_provider_accounts(
|
||||
Revision::new(1).expect("initial revision"),
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
|
||||
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
|
||||
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
|
||||
})
|
||||
.await
|
||||
.expect("create imported account");
|
||||
assert_eq!(imported.account_ids, ["acct_admin_upsert"]);
|
||||
let revision = imported.config_revision;
|
||||
sqlx::query(
|
||||
"update provider_accounts
|
||||
set provider_quota_json = '{}'::jsonb, quota_observed_at = now(), updated_at = now()
|
||||
@@ -676,14 +647,11 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
updated.availability = ProviderAccountAvailability::Banned;
|
||||
updated.availability_reason = Some("upstream_account_deactivated".to_owned());
|
||||
let imported = repository
|
||||
.import_provider_accounts(
|
||||
revision,
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![updated],
|
||||
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![updated],
|
||||
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
|
||||
})
|
||||
.await
|
||||
.expect("update the same imported identity");
|
||||
assert_eq!(imported.account_ids, ["acct_admin_upsert"]);
|
||||
@@ -715,14 +683,11 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
assert_eq!(row.5.as_deref(), Some("upstream_account_deactivated"));
|
||||
|
||||
repository
|
||||
.import_provider_accounts(
|
||||
revision,
|
||||
ImportProviderAccounts {
|
||||
scope,
|
||||
accounts: vec![account("acct_admin_upsert", "another-user")],
|
||||
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope,
|
||||
accounts: vec![account("acct_admin_upsert", "another-user")],
|
||||
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
|
||||
})
|
||||
.await
|
||||
.expect_err("an existing account ID must not be rebound");
|
||||
assert_eq!(current_revision(&database.pool).await, 3);
|
||||
@@ -859,18 +824,14 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
cooldown_account.availability = ProviderAccountAvailability::Cooldown;
|
||||
cooldown_account.cooldown_until = Some(Utc::now() + TimeDelta::minutes(10));
|
||||
let imported = repository
|
||||
.import_provider_accounts(
|
||||
Revision::new(1).expect("initial revision"),
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_a", "user-admin-a"), cooldown_account],
|
||||
audit: audit("audit_account_batch", "import_batch", "provider_accounts"),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_a", "user-admin-a"), cooldown_account],
|
||||
audit: audit("audit_account_batch", "import_batch", "provider_accounts"),
|
||||
})
|
||||
.await
|
||||
.expect("import provider accounts");
|
||||
let revision = imported.config_revision;
|
||||
assert_eq!(revision.get(), 2);
|
||||
assert_eq!(imported.config_revision.get(), 2);
|
||||
let ready: (bool, String, Option<chrono::DateTime<Utc>>) = sqlx::query_as(
|
||||
"select enabled, availability, cooldown_until from provider_accounts where id = $1",
|
||||
)
|
||||
@@ -891,21 +852,18 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
assert!(cooldown.2.is_some());
|
||||
|
||||
repository
|
||||
.import_provider_accounts(
|
||||
revision,
|
||||
ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![
|
||||
account("acct_admin_transient", "user-admin-transient"),
|
||||
account("acct_admin_a", "user-admin-duplicate"),
|
||||
],
|
||||
audit: audit(
|
||||
"audit_account_failed_batch",
|
||||
"import_batch",
|
||||
"provider_accounts",
|
||||
),
|
||||
},
|
||||
)
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
scope: scope.clone(),
|
||||
accounts: vec![
|
||||
account("acct_admin_transient", "user-admin-transient"),
|
||||
account("acct_admin_a", "user-admin-duplicate"),
|
||||
],
|
||||
audit: audit(
|
||||
"audit_account_failed_batch",
|
||||
"import_batch",
|
||||
"provider_accounts",
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect_err("duplicate batch row must roll back the entire import");
|
||||
assert_eq!(current_revision(&database.pool).await, 2);
|
||||
@@ -918,15 +876,12 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
provider_kind: "xai".to_owned(),
|
||||
};
|
||||
let wrong_scope_error = repository
|
||||
.rotate_provider_account(
|
||||
revision,
|
||||
RotateProviderAccount {
|
||||
scope: wrong_scope,
|
||||
profile: profile("acct_admin_a", "wrong scope"),
|
||||
credential: credential_update("acct_admin_a", 1, "wrong-scope-secret"),
|
||||
audit: audit("audit_wrong_scope", "rotate", "acct_admin_a"),
|
||||
},
|
||||
)
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
scope: wrong_scope,
|
||||
profile: profile("acct_admin_a", "wrong scope"),
|
||||
credential: credential_update("acct_admin_a", 1, "wrong-scope-secret"),
|
||||
audit: audit("audit_wrong_scope", "rotate", "acct_admin_a"),
|
||||
})
|
||||
.await
|
||||
.expect_err("Provider endpoint must not rotate another Provider account");
|
||||
assert!(matches!(
|
||||
@@ -939,32 +894,27 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
assert_eq!(current_revision(&database.pool).await, 2);
|
||||
|
||||
let rotation = repository
|
||||
.rotate_provider_account(
|
||||
revision,
|
||||
RotateProviderAccount {
|
||||
scope: scope.clone(),
|
||||
profile: profile("acct_admin_a", "rotated account"),
|
||||
credential: credential_update("acct_admin_a", 1, "rotated-secret"),
|
||||
audit: audit("audit_account_rotate", "rotate", "acct_admin_a"),
|
||||
},
|
||||
)
|
||||
.rotate_provider_account(RotateProviderAccount {
|
||||
scope: scope.clone(),
|
||||
profile: profile("acct_admin_a", "rotated account"),
|
||||
credential: credential_update("acct_admin_a", 1, "rotated-secret"),
|
||||
audit: audit("audit_account_rotate", "rotate", "acct_admin_a"),
|
||||
})
|
||||
.await
|
||||
.expect("rotate provider account");
|
||||
assert_eq!(rotation.config_revision.get(), 3);
|
||||
assert_eq!(rotation.credential_revision.get(), 2);
|
||||
|
||||
let revision = repository
|
||||
.set_provider_account_enabled_admin(
|
||||
rotation.config_revision,
|
||||
SetProviderAccountEnabled {
|
||||
scope: scope.clone(),
|
||||
account_id: "acct_admin_a".to_owned(),
|
||||
enabled: false,
|
||||
audit: audit("audit_account_disable", "disable", "acct_admin_a"),
|
||||
},
|
||||
)
|
||||
.set_provider_account_enabled_admin(SetProviderAccountEnabled {
|
||||
scope: scope.clone(),
|
||||
account_id: "acct_admin_a".to_owned(),
|
||||
enabled: false,
|
||||
audit: audit("audit_account_disable", "disable", "acct_admin_a"),
|
||||
})
|
||||
.await
|
||||
.expect("disable provider account");
|
||||
assert_eq!(revision.get(), 4);
|
||||
let exports = repository
|
||||
.export_provider_accounts(
|
||||
scope.clone(),
|
||||
@@ -977,14 +927,11 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
assert!(!format!("{exports:?}").contains("rotated-secret"));
|
||||
|
||||
let revision = repository
|
||||
.delete_provider_accounts_admin(
|
||||
revision,
|
||||
DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: vec!["acct_admin_a".to_owned(), "acct_admin_b".to_owned()],
|
||||
audit: audit("audit_account_delete", "delete", "provider_accounts"),
|
||||
},
|
||||
)
|
||||
.delete_provider_accounts_admin(DeleteProviderAccounts {
|
||||
scope,
|
||||
account_ids: vec!["acct_admin_a".to_owned(), "acct_admin_b".to_owned()],
|
||||
audit: audit("audit_account_delete", "delete", "provider_accounts"),
|
||||
})
|
||||
.await
|
||||
.expect("delete selected accounts regardless of enabled state");
|
||||
assert_eq!(revision.get(), 5);
|
||||
|
||||
@@ -942,7 +942,6 @@ struct PendingDocument {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PendingMutationDocument {
|
||||
schema_version: u64,
|
||||
expected_config_revision: u64,
|
||||
provider_kind: String,
|
||||
target: PendingTargetDocument,
|
||||
owner: PendingOwnerDocument,
|
||||
@@ -1025,10 +1024,6 @@ fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value
|
||||
"schema_version".to_owned(),
|
||||
Value::Number(Number::from(PENDING_DOCUMENT_SCHEMA_VERSION)),
|
||||
);
|
||||
document.insert(
|
||||
"expected_config_revision".to_owned(),
|
||||
Value::Number(Number::from(mutation.expected_config_revision().get())),
|
||||
);
|
||||
document.insert(
|
||||
"provider_kind".to_owned(),
|
||||
Value::String(mutation.provider_kind().as_str().to_owned()),
|
||||
@@ -1119,8 +1114,6 @@ fn decode_mutation(
|
||||
if document.schema_version != PENDING_DOCUMENT_SCHEMA_VERSION {
|
||||
return Err(CodexOAuthPendingStoreError::InvalidValue);
|
||||
}
|
||||
let expected_config_revision = Revision::new(document.expected_config_revision)
|
||||
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
|
||||
let provider_kind = ProviderKind::new(document.provider_kind)
|
||||
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
|
||||
let target = match document.target {
|
||||
@@ -1147,7 +1140,6 @@ fn decode_mutation(
|
||||
request_id: document.started_request_id,
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
expected_config_revision,
|
||||
provider_kind,
|
||||
target,
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
|
||||
@@ -329,7 +329,6 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
|
||||
let started = bundle
|
||||
.admin_provider()
|
||||
.start_authorization(PendingAuthorizationMutation::new(
|
||||
Revision::new(7).expect("revision"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "OAuth account".to_owned(),
|
||||
@@ -346,12 +345,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
|
||||
.get("mutation")
|
||||
.and_then(Value::as_object)
|
||||
.expect("pending mutation");
|
||||
assert_eq!(
|
||||
mutation
|
||||
.get("expected_config_revision")
|
||||
.and_then(Value::as_u64),
|
||||
Some(7)
|
||||
);
|
||||
assert!(mutation.get("expected_config_revision").is_none());
|
||||
assert_eq!(
|
||||
mutation.get("started_request_id").and_then(Value::as_str),
|
||||
Some("request-start")
|
||||
|
||||
@@ -172,7 +172,6 @@ fn owner_context(request_id: &str) -> MutationContext {
|
||||
fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
|
||||
let context = owner_context(request_id);
|
||||
PendingAuthorizationMutation::new(
|
||||
Revision::new(1).expect("revision"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "OAuth Account".to_owned(),
|
||||
@@ -184,7 +183,6 @@ fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
|
||||
fn reauthorization_mutation(request_id: &str, account_id: &str) -> PendingAuthorizationMutation {
|
||||
let context = owner_context(request_id);
|
||||
PendingAuthorizationMutation::new(
|
||||
Revision::new(1).expect("revision"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: ProviderAccountId::new(account_id).expect("account id"),
|
||||
|
||||
@@ -1071,7 +1071,6 @@ struct PendingDocument {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PendingMutationDocument {
|
||||
expected_config_revision: u64,
|
||||
provider_kind: String,
|
||||
target: PendingTargetDocument,
|
||||
owner: PendingOwnerDocument,
|
||||
@@ -1126,10 +1125,6 @@ fn encode_pending(pending: &StoredXaiAuthorization) -> Map<String, Value> {
|
||||
|
||||
fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value> {
|
||||
let mut document = Map::new();
|
||||
document.insert(
|
||||
"expected_config_revision".to_owned(),
|
||||
Value::Number(Number::from(mutation.expected_config_revision().get())),
|
||||
);
|
||||
document.insert(
|
||||
"provider_kind".to_owned(),
|
||||
Value::String(mutation.provider_kind().as_str().to_owned()),
|
||||
@@ -1256,8 +1251,6 @@ fn decode_mutation(
|
||||
request_id: document.started_request_id,
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
Revision::new(document.expected_config_revision)
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?,
|
||||
provider_kind,
|
||||
target,
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
|
||||
@@ -399,6 +399,7 @@ fn compile_grok_model_capabilities(model: GrokCatalogModel) -> ProviderModelCapa
|
||||
.max_output_tokens()
|
||||
.map(std::num::NonZeroU64::get),
|
||||
)
|
||||
.with_upstream_feature_validation()
|
||||
.with_feature(
|
||||
Feature::Reasoning,
|
||||
support(model.capabilities().reasoning_effort()),
|
||||
@@ -441,10 +442,7 @@ fn default_grok_model_presentation() -> ModelPresentation {
|
||||
|
||||
fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
|
||||
let slug = model.request_model().as_str();
|
||||
let known_grok_4_5 = matches!(
|
||||
slug,
|
||||
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
|
||||
);
|
||||
let known_grok_4_5 = is_known_grok_4_5_model(slug);
|
||||
let reasoning_evidence = model.capabilities().reasoning_effort();
|
||||
let catalog_reasoning_efforts = model
|
||||
.capabilities()
|
||||
@@ -513,6 +511,13 @@ fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
|
||||
.with_hidden(model.metadata().hidden().unwrap_or(false))
|
||||
}
|
||||
|
||||
fn is_known_grok_4_5_model(slug: &str) -> bool {
|
||||
matches!(
|
||||
slug,
|
||||
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct XaiSessionState {
|
||||
account_id: String,
|
||||
|
||||
@@ -335,12 +335,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() {
|
||||
.get("mutation")
|
||||
.and_then(Value::as_object)
|
||||
.expect("pending mutation");
|
||||
assert_eq!(
|
||||
mutation
|
||||
.get("expected_config_revision")
|
||||
.and_then(Value::as_u64),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(mutation.get("expected_config_revision"), None);
|
||||
assert_eq!(
|
||||
mutation.get("started_request_id").and_then(Value::as_str),
|
||||
Some("request-start")
|
||||
@@ -555,7 +550,6 @@ fn pending_payload(flow_id: &str, owner_ref: &str) -> OpaqueProviderData {
|
||||
"expires_at": (Utc::now() + chrono::Duration::minutes(30)).to_rfc3339(),
|
||||
"server_state": server_state,
|
||||
"mutation": {
|
||||
"expected_config_revision": 7,
|
||||
"provider_kind": "xai",
|
||||
"target": {
|
||||
"kind": "create",
|
||||
|
||||
@@ -60,11 +60,11 @@ fn observed_transport_metrics() -> GrokInferenceTransportMetrics {
|
||||
))
|
||||
}
|
||||
|
||||
const CATALOG_WITHOUT_TOOL_METADATA: &[u8] = br#"{
|
||||
const CATALOG_WITHOUT_FEATURE_METADATA: &[u8] = br#"{
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"id": "grok-4.5-catalog-entry",
|
||||
"model": "grok-4.5",
|
||||
"id": "grok-catalog-entry",
|
||||
"model": "grok-catalog-entry",
|
||||
"contextWindow": 1000000,
|
||||
"maxCompletionTokens": 131072,
|
||||
"apiBackend": "responses",
|
||||
@@ -368,13 +368,13 @@ impl GrokModelCatalogTransport for StaticCatalogTransport {
|
||||
}
|
||||
}
|
||||
|
||||
struct CatalogWithoutToolMetadataTransport;
|
||||
struct CatalogWithoutFeatureMetadataTransport;
|
||||
|
||||
impl GrokModelCatalogTransport for CatalogWithoutToolMetadataTransport {
|
||||
impl GrokModelCatalogTransport for CatalogWithoutFeatureMetadataTransport {
|
||||
fn execute(&self, _: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> {
|
||||
Box::pin(async {
|
||||
Ok(GrokModelCatalogTransportResponse::new(
|
||||
CATALOG_WITHOUT_TOOL_METADATA,
|
||||
CATALOG_WITHOUT_FEATURE_METADATA,
|
||||
None,
|
||||
))
|
||||
})
|
||||
@@ -1918,7 +1918,7 @@ async fn missing_catalog_tool_metadata_keeps_build_tools_routable() {
|
||||
StubSelector::success(),
|
||||
StubInferenceTransport::success(),
|
||||
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
|
||||
Arc::new(CatalogWithoutToolMetadataTransport),
|
||||
Arc::new(CatalogWithoutFeatureMetadataTransport),
|
||||
)
|
||||
.await;
|
||||
let capabilities = provider
|
||||
@@ -1935,3 +1935,29 @@ async fn missing_catalog_tool_metadata_keeps_build_tools_routable() {
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_catalog_feature_metadata_keeps_build_responses_routable() {
|
||||
let provider = provider_with_catalog_transport(
|
||||
StubSelector::success(),
|
||||
StubInferenceTransport::success(),
|
||||
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
|
||||
Arc::new(CatalogWithoutFeatureMetadataTransport),
|
||||
)
|
||||
.await;
|
||||
let capabilities = provider
|
||||
.query_model_capabilities()
|
||||
.await
|
||||
.expect("capabilities");
|
||||
|
||||
assert!(
|
||||
capabilities[0]
|
||||
.capabilities()
|
||||
.match_requirements(
|
||||
&gateway_core::operation::CapabilityRequirements::new(OperationKind::Generate)
|
||||
.require(Feature::Vision)
|
||||
.require(Feature::JsonSchema)
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
+15
-19
@@ -41,13 +41,13 @@ Client Key 固定绑定一个 Provider:`openai` 或 `xai`。同一次请求不
|
||||
|
||||
错误响应仍使用 `code`、`message`、`data`,其中 `data` 为 `null`。稳定业务码包括参数错误
|
||||
`40001`、会话缺失 `40101`、登录凭据错误 `40102`、管理 API Key 错误 `40103`、资源不存在
|
||||
`40401`、revision 冲突 `40901`、内部错误 `50001`、上游失败 `50201` 和依赖不可用 `50301`。
|
||||
`40401`、业务冲突 `40901`、内部错误 `50001`、上游失败 `50201` 和依赖不可用 `50301`。
|
||||
|
||||
### 配置 revision
|
||||
### 管理写入一致性
|
||||
|
||||
会改变路由快照或安全配置的 mutation 使用 `expectedConfigRevision` 做乐观并发控制。客户端应从
|
||||
账号、Client Key 或设置查询结果读取最新 `configRevision`;收到 `40901` 后重新读取,不应盲目重试
|
||||
旧 revision。
|
||||
Admin API 不暴露全局配置版本,mutation 请求也不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进内部配置 revision,并用于快照发布与审计。
|
||||
|
||||
单个 credential 的轮换或重新授权仍使用 `expectedCredentialRevision`,它只保护该 credential 本身,不是全局配置锁。
|
||||
|
||||
## 2. 健康检查
|
||||
|
||||
@@ -95,12 +95,12 @@ OpenAI 路径保留客户端 Responses wire 语义,上游事件字节在 HTTP
|
||||
| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`search`、`status`、排序字段 | 分页查询账号与汇总 |
|
||||
| `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 |
|
||||
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, expectedConfigRevision, data }` | 导入或按上游身份更新账号 |
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId, expectedConfigRevision }` | 手工刷新账号 credential |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号 |
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新账号 credential |
|
||||
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
|
||||
| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId, expectedConfigRevision }` | 启用账号 |
|
||||
| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId, expectedConfigRevision }` | 禁用账号 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds, expectedConfigRevision }` | 批量删除 1–200 个账号 |
|
||||
| `POST` | `/api/admin/accounts/enable` | `{ provider, accountId }` | 启用账号 |
|
||||
| `POST` | `/api/admin/accounts/disable` | `{ provider, accountId }` | 禁用账号 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 立即刷新该账号额度 |
|
||||
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
|
||||
@@ -150,7 +150,6 @@ OpenAI rotation 请求字段为:
|
||||
{
|
||||
"provider": "openai",
|
||||
"accountId": "acct_...",
|
||||
"expectedConfigRevision": 1,
|
||||
"expectedCredentialRevision": 1,
|
||||
"accessToken": "...",
|
||||
"refreshToken": "..."
|
||||
@@ -162,7 +161,6 @@ OAuth start 使用:
|
||||
```json
|
||||
{
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "account name",
|
||||
"accountId": null,
|
||||
"expectedCredentialRevision": null
|
||||
@@ -179,18 +177,17 @@ OAuth start 使用:
|
||||
| `POST` | `/api/admin/client-keys/create` | 创建字段 | 创建绑定 Provider 的 Client Key |
|
||||
| `GET` | `/api/admin/client-keys/reveal` | `id` | 显式读取完整明文 Key |
|
||||
| `POST` | `/api/admin/client-keys/update` | 更新字段 | 原子更新名称、Provider 和限额 |
|
||||
| `POST` | `/api/admin/client-keys/enable` | `{ id, expectedConfigRevision }` | 启用 |
|
||||
| `POST` | `/api/admin/client-keys/disable` | `{ id, expectedConfigRevision }` | 禁用 |
|
||||
| `POST` | `/api/admin/client-keys/delete` | `{ id, expectedConfigRevision }` | 删除 |
|
||||
| `POST` | `/api/admin/client-keys/enable` | `{ id }` | 启用 |
|
||||
| `POST` | `/api/admin/client-keys/disable` | `{ id }` | 禁用 |
|
||||
| `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 |
|
||||
|
||||
创建字段为 `expectedConfigRevision`、`name`、可选 `label`、`providerKind`、`maxConcurrency`、
|
||||
`requestsPerMinute`。更新请求再增加 `id`。创建和 reveal 响应会返回完整明文 Key,调用方必须立即安全保存。
|
||||
创建字段为 `name`、可选 `label`、`providerKind`、`maxConcurrency`、`requestsPerMinute`。更新请求再增加 `id`。创建和 reveal 响应会返回完整明文 Key,调用方必须立即安全保存。
|
||||
|
||||
## 7. 运行设置
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/admin/settings` | 读取运行设置与 `configRevision` |
|
||||
| `GET` | `/api/admin/settings` | 读取运行设置 |
|
||||
| `POST` | `/api/admin/settings/update` | 原子替换全部运行设置 |
|
||||
| `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 |
|
||||
| `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key |
|
||||
@@ -199,7 +196,6 @@ OAuth start 使用:
|
||||
设置更新字段包括:
|
||||
|
||||
```text
|
||||
expectedConfigRevision
|
||||
modelMappings
|
||||
refreshMarginSeconds
|
||||
refreshConcurrency
|
||||
|
||||
@@ -114,11 +114,10 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁
|
||||
|
||||
`refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。
|
||||
|
||||
## 7. 控制面与 revision
|
||||
## 7. 控制面与内部 revision
|
||||
|
||||
管理写入使用 `expected_config_revision` 做全局乐观并发控制。会改变调度快照或安全配置的写入,必须在同一 PostgreSQL 事务中:
|
||||
Admin API 不要求客户端提交配置 revision,也不向客户端暴露配置 revision。会改变调度快照或安全配置的写入,必须在同一 PostgreSQL 事务中:
|
||||
|
||||
- 校验 expected revision;
|
||||
- 执行业务 mutation;
|
||||
- 推进 `runtime_settings.config_revision`;
|
||||
- 写入脱敏 `admin_audit_events`。
|
||||
|
||||
@@ -134,14 +134,12 @@ export interface AccountSummary {
|
||||
}
|
||||
|
||||
export interface AccountListResponse {
|
||||
configRevision: number
|
||||
items: Account[]
|
||||
page: AccountPageMeta
|
||||
summary: AccountSummary
|
||||
}
|
||||
|
||||
export interface AccountRefreshResponse {
|
||||
configRevision: number
|
||||
account: Account
|
||||
result?: string
|
||||
error?: string
|
||||
@@ -156,19 +154,16 @@ export interface AccountModelsResponse {
|
||||
}
|
||||
|
||||
export interface AccountImportResponse {
|
||||
configRevision: number
|
||||
importedCount: number
|
||||
accountIds: string[]
|
||||
}
|
||||
|
||||
export interface AccountMutationResponse {
|
||||
configRevision: number
|
||||
accountId: string
|
||||
credentialRevision?: number
|
||||
}
|
||||
|
||||
export interface AccountDeletionResponse {
|
||||
configRevision: number
|
||||
deletedCount: number
|
||||
accountIds: string[]
|
||||
}
|
||||
|
||||
@@ -15,14 +15,12 @@ export interface ApiKey {
|
||||
}
|
||||
|
||||
export interface ApiKeyListResponse {
|
||||
configRevision: number
|
||||
items: ApiKey[]
|
||||
nextCursor: string | null
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ApiKeyCreateResponse {
|
||||
configRevision: number
|
||||
id: string
|
||||
prefix: string
|
||||
plaintextKey: string
|
||||
@@ -34,7 +32,6 @@ export interface ApiKeyRevealResponse {
|
||||
}
|
||||
|
||||
export interface ApiKeyMutationResponse {
|
||||
configRevision: number
|
||||
id: string
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import request from '../request'
|
||||
export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky'
|
||||
|
||||
export interface RuntimeSettings {
|
||||
configRevision: number
|
||||
modelMappings: Record<string, string>
|
||||
refreshMarginSeconds: number
|
||||
refreshConcurrency: number
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
refreshAccount,
|
||||
refreshAccountQuota,
|
||||
} from '@/api'
|
||||
import { ApiError } from '@/api/request'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
@@ -39,14 +38,12 @@ export function useAccountMutations(options: {
|
||||
sort: Ref<BaseTableSort | undefined>
|
||||
selectedIds: Ref<Set<string>>
|
||||
totalAccounts: Ref<number>
|
||||
configRevision: Ref<number>
|
||||
reload: () => Promise<unknown>
|
||||
}) {
|
||||
const loadAccounts = options.reload
|
||||
const { downloadJson } = useDownload()
|
||||
const onboarding = useAccountOnboarding({
|
||||
reload: loadAccounts,
|
||||
configRevision: options.configRevision,
|
||||
})
|
||||
const selectedAccountsById = new Map<string, AccountRow>()
|
||||
const showDeleteModal = ref(false)
|
||||
@@ -169,10 +166,8 @@ export function useAccountMutations(options: {
|
||||
const result = await withMinimumDuration(() =>
|
||||
refreshAccount({
|
||||
accountId,
|
||||
expectedConfigRevision: options.configRevision.value,
|
||||
}),
|
||||
)
|
||||
onboarding.commitConfigRevision(result.configRevision)
|
||||
await loadAccounts()
|
||||
if (result.result === 'skipped') {
|
||||
toast.warning(result.error || 'Token 正在刷新中')
|
||||
@@ -308,22 +303,12 @@ export function useAccountMutations(options: {
|
||||
const payload = {
|
||||
provider: account.provider,
|
||||
accountId: account.id,
|
||||
expectedConfigRevision: await onboarding.requireConfigRevision(),
|
||||
}
|
||||
try {
|
||||
const result = action === 'enable'
|
||||
? await enableAccount(payload)
|
||||
: await disableAccount(payload)
|
||||
if (!result)
|
||||
throw new Error(`不支持的 Provider:${account.provider}`)
|
||||
onboarding.commitConfigRevision(result.configRevision)
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
await loadAccounts()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const result = action === 'enable'
|
||||
? await enableAccount(payload)
|
||||
: await disableAccount(payload)
|
||||
if (!result)
|
||||
throw new Error(`不支持的 Provider:${account.provider}`)
|
||||
}
|
||||
|
||||
async function deleteAccountBatch(accounts: AccountRow[]) {
|
||||
@@ -333,20 +318,10 @@ export function useAccountMutations(options: {
|
||||
const payload = {
|
||||
provider: account.provider,
|
||||
accountIds: accounts.map(account => account.id),
|
||||
expectedConfigRevision: await onboarding.requireConfigRevision(),
|
||||
}
|
||||
try {
|
||||
const result = await deleteAccounts(payload)
|
||||
if (!result)
|
||||
throw new Error(`不支持的 Provider:${account.provider}`)
|
||||
onboarding.commitConfigRevision(result.configRevision)
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
await loadAccounts()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const result = await deleteAccounts(payload)
|
||||
if (!result)
|
||||
throw new Error(`不支持的 Provider:${account.provider}`)
|
||||
}
|
||||
|
||||
function accountDeletionGroups(accounts: AccountRow[]) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
importAccounts,
|
||||
startAccountOAuth,
|
||||
} from '@/api'
|
||||
import { ApiError } from '@/api/request'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
@@ -21,11 +20,9 @@ interface MixedImportDocument {
|
||||
|
||||
export function useAccountOnboarding(options: {
|
||||
reload: () => Promise<unknown>
|
||||
configRevision: { value: number }
|
||||
}) {
|
||||
const createModalOpen = shallowRef(false)
|
||||
const reauthorizingAccount = shallowRef<AccountRow | null>(null)
|
||||
const configRevision = options.configRevision
|
||||
const creatingAccountAction = useAsyncAction()
|
||||
const authorizingOAuthAction = useAsyncAction()
|
||||
const creatingAccount = creatingAccountAction.loading
|
||||
@@ -70,7 +67,7 @@ export function useAccountOnboarding(options: {
|
||||
async () => {
|
||||
const input = await newAccountInput()
|
||||
const account = reauthorizingAccount.value
|
||||
const result = await withConflictRefresh(() => startAccountOAuth({
|
||||
const result = await startAccountOAuth({
|
||||
...input,
|
||||
...(account
|
||||
? {
|
||||
@@ -78,7 +75,7 @@ export function useAccountOnboarding(options: {
|
||||
expectedCredentialRevision: account.credentialRevision,
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
})
|
||||
|
||||
createForm.value = {
|
||||
...createForm.value,
|
||||
@@ -104,12 +101,11 @@ export function useAccountOnboarding(options: {
|
||||
const callbackUrl = createForm.value.oauthCallback.trim()
|
||||
if (!callbackUrl)
|
||||
throw new Error('请粘贴 OAuth 回调地址')
|
||||
const result = await withConflictRefresh(() => completeAccountOAuth({
|
||||
await completeAccountOAuth({
|
||||
provider: createForm.value.provider,
|
||||
flowId: createForm.value.oauthFlowId,
|
||||
callbackUrl,
|
||||
}))
|
||||
commitConfigRevision(result.configRevision)
|
||||
})
|
||||
await finishCreate(
|
||||
reauthorizingAccount.value
|
||||
? '账号重新授权成功'
|
||||
@@ -144,24 +140,10 @@ export function useAccountOnboarding(options: {
|
||||
void handleAuthorizeOAuth()
|
||||
}
|
||||
|
||||
async function requireConfigRevision() {
|
||||
if (configRevision.value <= 0)
|
||||
await options.reload()
|
||||
if (configRevision.value <= 0)
|
||||
throw new Error('当前配置 revision 不可用')
|
||||
return configRevision.value
|
||||
}
|
||||
|
||||
function commitConfigRevision(revision: number) {
|
||||
if (revision > 0)
|
||||
configRevision.value = revision
|
||||
}
|
||||
|
||||
async function newAccountInput() {
|
||||
const account = reauthorizingAccount.value
|
||||
return {
|
||||
provider: createForm.value.provider,
|
||||
expectedConfigRevision: await requireConfigRevision(),
|
||||
name: account?.name || account?.email || `${createForm.value.provider} OAuth`,
|
||||
}
|
||||
}
|
||||
@@ -170,14 +152,12 @@ export function useAccountOnboarding(options: {
|
||||
const data = parseImportJson(createForm.value.importText)
|
||||
if (Array.isArray(data) || typeof data !== 'object' || data === null)
|
||||
throw new Error('导入文件必须是 JSON object')
|
||||
const expectedConfigRevision = await requireConfigRevision()
|
||||
const result = await withConflictRefresh(() => importAccounts({
|
||||
const result = await importAccounts({
|
||||
provider: createForm.value.provider,
|
||||
expectedConfigRevision,
|
||||
data,
|
||||
}))
|
||||
commitConfigRevision(result.configRevision)
|
||||
return createForm.value.provider === 'xai' ? 'xAI 账号已导入' : 'OpenAI 账号已导入'
|
||||
})
|
||||
const provider = createForm.value.provider === 'xai' ? 'xAI' : 'OpenAI'
|
||||
return `${provider} 账号已导入 ${result.importedCount} 个`
|
||||
}
|
||||
|
||||
async function importMixedAccountDocument() {
|
||||
@@ -187,13 +167,10 @@ export function useAccountOnboarding(options: {
|
||||
|
||||
for (const entry of documents) {
|
||||
try {
|
||||
const expectedConfigRevision = await requireConfigRevision()
|
||||
const result = await withConflictRefresh(() => importAccounts({
|
||||
const result = await importAccounts({
|
||||
provider: entry.provider,
|
||||
expectedConfigRevision,
|
||||
data: entry.document,
|
||||
}))
|
||||
commitConfigRevision(result.configRevision)
|
||||
})
|
||||
importedCount += result.importedCount
|
||||
}
|
||||
catch (error) {
|
||||
@@ -220,18 +197,6 @@ export function useAccountOnboarding(options: {
|
||||
toast.success(message)
|
||||
}
|
||||
|
||||
async function withConflictRefresh<T>(task: () => Promise<T>) {
|
||||
try {
|
||||
return await task()
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
await options.reload()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => createForm.value.provider,
|
||||
() => {
|
||||
@@ -250,14 +215,11 @@ export function useAccountOnboarding(options: {
|
||||
reauthorizingAccount,
|
||||
creatingAccount,
|
||||
authorizingOAuth,
|
||||
configRevision,
|
||||
createForm,
|
||||
handleCreate,
|
||||
handleAuthorizeOAuth,
|
||||
openCreateAccount,
|
||||
openReauthorizeAccount,
|
||||
requireConfigRevision,
|
||||
commitConfigRevision,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export function useAccountsQuery() {
|
||||
const searchQuery = shallowRef('')
|
||||
const providerQuery = shallowRef('')
|
||||
const statusQuery = shallowRef('')
|
||||
const configRevision = shallowRef(0)
|
||||
const sort = shallowRef<BaseTableSort>()
|
||||
const accountSummary = shallowRef({
|
||||
total: 0,
|
||||
@@ -34,7 +33,6 @@ export function useAccountsQuery() {
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
accountSummary.value = result.summary
|
||||
configRevision.value = result.configRevision || configRevision.value
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(errorMessage(error, '账号加载失败'))
|
||||
@@ -94,7 +92,6 @@ export function useAccountsQuery() {
|
||||
statusQuery,
|
||||
sort,
|
||||
accountSummary,
|
||||
configRevision,
|
||||
accountPagination,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
|
||||
@@ -36,7 +36,6 @@ const {
|
||||
statusQuery,
|
||||
sort,
|
||||
accountSummary,
|
||||
configRevision,
|
||||
accountPagination,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
@@ -78,7 +77,6 @@ const {
|
||||
sort,
|
||||
selectedIds,
|
||||
totalAccounts,
|
||||
configRevision,
|
||||
reload: loadAccounts,
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number]
|
||||
|
||||
export function useApiKeyMutations(options: {
|
||||
selectedIds: Ref<Set<string>>
|
||||
configRevision: Ref<number>
|
||||
reload: () => Promise<unknown>
|
||||
}) {
|
||||
const copyText = useCopyText()
|
||||
@@ -50,13 +49,6 @@ export function useApiKeyMutations(options: {
|
||||
}
|
||||
})
|
||||
|
||||
function currentRevision() {
|
||||
if (options.configRevision.value <= 0) {
|
||||
throw new Error('配置版本尚未加载,请刷新后重试')
|
||||
}
|
||||
return options.configRevision.value
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (creatingKey.value)
|
||||
return
|
||||
@@ -70,7 +62,6 @@ export function useApiKeyMutations(options: {
|
||||
const name = createForm.value.name.trim()
|
||||
const providerKind = createForm.value.providerKind
|
||||
const result = await createApiKey({
|
||||
expectedConfigRevision: currentRevision(),
|
||||
name,
|
||||
label: createForm.value.label.trim() || undefined,
|
||||
providerKind,
|
||||
@@ -78,7 +69,6 @@ export function useApiKeyMutations(options: {
|
||||
requestsPerMinute: 0,
|
||||
})
|
||||
|
||||
options.configRevision.value = result.configRevision
|
||||
createdKey.value = result.plaintextKey
|
||||
createdKeyName.value = name
|
||||
createdKeyProviderKind.value = providerKind
|
||||
@@ -145,22 +135,18 @@ export function useApiKeyMutations(options: {
|
||||
}
|
||||
|
||||
async function deleteKey(keyId: string) {
|
||||
const deleted = await deleteApiKey({
|
||||
await deleteApiKey({
|
||||
id: keyId,
|
||||
expectedConfigRevision: currentRevision(),
|
||||
})
|
||||
options.configRevision.value = deleted.configRevision
|
||||
}
|
||||
|
||||
async function handleToggleStatus(key: ApiKeyRow) {
|
||||
await updatingStatusKeys.run(key.id, async () => {
|
||||
try {
|
||||
const mutation = key.enabled ? disableApiKey : enableApiKey
|
||||
const result = await mutation({
|
||||
await mutation({
|
||||
id: key.id,
|
||||
expectedConfigRevision: currentRevision(),
|
||||
})
|
||||
options.configRevision.value = result.configRevision
|
||||
await loadApiKeys()
|
||||
toast.success(key.enabled ? '已禁用' : '已启用')
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export function useApiKeysQuery() {
|
||||
}>
|
||||
>([])
|
||||
const loading = shallowRef(false)
|
||||
const configRevision = shallowRef(0)
|
||||
const cursors = new Map<number, string | undefined>([[1, undefined]])
|
||||
let requestSequence = 0
|
||||
|
||||
@@ -59,15 +58,10 @@ export function useApiKeysQuery() {
|
||||
createdAtDisplay: formatDateTime(item.createdAt),
|
||||
lastUsedAtDisplay: item.lastUsedAt ? formatDateTime(item.lastUsedAt) : '—',
|
||||
}))
|
||||
configRevision.value = result.configRevision
|
||||
total.value = result.total
|
||||
page.value = targetPage
|
||||
}
|
||||
|
||||
function paginationRevisionChanged(result: Awaited<ReturnType<typeof getApiKeys>>) {
|
||||
return configRevision.value > 0 && result.configRevision !== configRevision.value
|
||||
}
|
||||
|
||||
async function execute(targetPage = page.value) {
|
||||
const requestId = ++requestSequence
|
||||
const requestedPage = Math.max(1, targetPage)
|
||||
@@ -83,10 +77,6 @@ export function useApiKeysQuery() {
|
||||
result = await fetchPage(cursors.get(currentPage), requestedPageSize, requestedSearch)
|
||||
if (requestId !== requestSequence)
|
||||
return false
|
||||
if (paginationRevisionChanged(result)) {
|
||||
resetCursorPagination()
|
||||
return execute(1)
|
||||
}
|
||||
|
||||
if (!result.nextCursor) {
|
||||
applyPage(result, currentPage)
|
||||
@@ -100,11 +90,6 @@ export function useApiKeysQuery() {
|
||||
result = await fetchPage(cursors.get(currentPage), requestedPageSize, requestedSearch)
|
||||
if (requestId !== requestSequence)
|
||||
return false
|
||||
if (paginationRevisionChanged(result)) {
|
||||
resetCursorPagination()
|
||||
if (currentPage !== 1)
|
||||
return execute(1)
|
||||
}
|
||||
|
||||
if (result.nextCursor)
|
||||
cursors.set(currentPage + 1, result.nextCursor)
|
||||
@@ -162,7 +147,6 @@ export function useApiKeysQuery() {
|
||||
loading,
|
||||
apiKeys,
|
||||
loadApiKeys: reloadFromStart,
|
||||
configRevision,
|
||||
searchQuery,
|
||||
sort,
|
||||
apiKeyPagination,
|
||||
|
||||
@@ -26,7 +26,6 @@ const {
|
||||
loading,
|
||||
apiKeys,
|
||||
loadApiKeys,
|
||||
configRevision,
|
||||
searchQuery,
|
||||
sort,
|
||||
apiKeyPagination,
|
||||
@@ -58,7 +57,7 @@ const {
|
||||
copyToClipboard,
|
||||
revealPlaintextKey,
|
||||
copyApiKey,
|
||||
} = useApiKeyMutations({ selectedIds, configRevision, reload: loadApiKeys })
|
||||
} = useApiKeyMutations({ selectedIds, reload: loadApiKeys })
|
||||
|
||||
const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll } = usePageSelection(
|
||||
apiKeys,
|
||||
|
||||
@@ -13,7 +13,6 @@ export function useSettingsForm() {
|
||||
const error = shallowRef('')
|
||||
const mappings = ref<Array<{ requestedModel: string, upstreamModel: string }>>([])
|
||||
const form = reactive({
|
||||
configRevision: 0,
|
||||
refreshMarginSeconds: null as number | null,
|
||||
refreshConcurrency: null as number | null,
|
||||
maxConcurrentPerAccount: null as number | null,
|
||||
@@ -44,7 +43,6 @@ export function useSettingsForm() {
|
||||
const requestIntervalMsValue = numericModel('requestIntervalMs')
|
||||
|
||||
function applySettings(data: Awaited<ReturnType<typeof getSettings>>) {
|
||||
form.configRevision = data.configRevision
|
||||
form.refreshMarginSeconds = data.refreshMarginSeconds
|
||||
form.refreshConcurrency = data.refreshConcurrency
|
||||
form.maxConcurrentPerAccount = data.maxConcurrentPerAccount
|
||||
@@ -117,7 +115,6 @@ export function useSettingsForm() {
|
||||
try {
|
||||
saving.value = true
|
||||
const result = await updateSettings({
|
||||
expectedConfigRevision: form.configRevision,
|
||||
modelMappings: mappingPayload(),
|
||||
refreshMarginSeconds,
|
||||
refreshConcurrency,
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.0.12"
|
||||
version: "3.0.13"
|
||||
|
||||
Reference in New Issue
Block a user