refactor(gateway): remove provider instance layer

Collapse routing, health, circuit state, account pools, and admin APIs onto the fixed OpenAI and xAI Provider boundary while keeping credential runtime state account-scoped.

Remove configurable provider-instance and routing-catalog persistence, use fixed official endpoints in provider adapters, align account routes and frontend contracts, and mirror provider tests under their source modules.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-25 00:33:12 +08:00
co-authored by Codex
parent acaf98512c
commit 70f8afb122
134 changed files with 2828 additions and 10770 deletions
+4 -15
View File
@@ -16,9 +16,9 @@ pub mod ports;
mod use_case;
pub use use_case::{
accounts::AccountsService, auth::AuthService, catalog::CatalogService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
settings::SettingsService, system::SystemService, xai::XaiService,
accounts::AccountsService, auth::AuthService, client_keys::ClientKeyService,
observability::ObservabilityService, openai::OpenAiService, settings::SettingsService,
system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
@@ -28,7 +28,7 @@ use ports::{
system::SystemOperations,
};
use use_case::{
accounts::DefaultAccountsService, auth::DefaultAuthService, catalog::DefaultCatalogService,
accounts::DefaultAccountsService, auth::DefaultAuthService,
client_keys::DefaultClientKeyService, observability::DefaultObservabilityService,
openai::DefaultOpenAiService, settings::DefaultSettingsService, system::DefaultSystemService,
xai::DefaultXaiService,
@@ -140,7 +140,6 @@ pub enum AdminConfigError {
pub struct AdminServices {
auth: Arc<dyn AuthService>,
accounts: Arc<dyn AccountsService>,
catalog: Arc<dyn CatalogService>,
client_keys: Arc<dyn ClientKeyService>,
observability: Arc<dyn ObservabilityService>,
settings: Arc<dyn SettingsService>,
@@ -160,11 +159,6 @@ impl AdminServices {
self.accounts.as_ref()
}
#[must_use]
pub fn catalog(&self) -> &dyn CatalogService {
self.catalog.as_ref()
}
#[must_use]
pub fn client_keys(&self) -> &dyn ClientKeyService {
self.client_keys.as_ref()
@@ -242,7 +236,6 @@ pub async fn initialize(
let accounts = Arc::new(DefaultAccountsService::new(
store.accounts(),
store.catalog(),
store.settings(),
registry.clone(),
snapshot.clone(),
@@ -251,10 +244,6 @@ pub async fn initialize(
let services = AdminServices {
auth,
accounts,
catalog: Arc::new(DefaultCatalogService::new(
store.catalog(),
snapshot.clone(),
)),
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
@@ -5,7 +5,7 @@ use std::pin::Pin;
use chrono::{DateTime, Utc};
use futures::Stream;
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use gateway_core::routing::ProviderKind;
use super::{PageSize, Revision};
@@ -72,7 +72,6 @@ pub struct AccountListQuery {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub id: String,
pub provider_instance_id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub name: String,
pub email: Option<String>,
@@ -169,11 +168,11 @@ pub struct SetAccountEnabled {
pub enabled: bool,
}
/// 账号删除命令。
/// 账号批量删除命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAccount {
pub struct DeleteAccounts {
pub expected_config_revision: Revision,
pub account_id: String,
pub account_ids: Vec<String>,
}
/// 账号连接测试的语义事件。
@@ -1,89 +0,0 @@
//! Provider instance 目录的 Command 与 Result。
use chrono::{DateTime, Utc};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use super::{PageSize, Revision};
/// 一个可管理的 Provider instance。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstance {
pub id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub name: String,
pub base_url: String,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Provider instance 列表结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstanceCatalog {
pub config_revision: Revision,
pub items: Vec<ProviderInstance>,
}
/// Provider instance 详情及其所属控制面 revision。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstanceDetail {
pub config_revision: Revision,
pub item: ProviderInstance,
}
/// Provider instance 的稳定游标查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogListQuery {
pub cursor: Option<ProviderInstanceId>,
pub page_size: PageSize,
}
/// Provider instance 分页结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstancePage {
pub config_revision: Revision,
pub items: Vec<ProviderInstance>,
pub next_cursor: Option<ProviderInstanceId>,
}
/// 创建 Provider instance。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateProviderInstance {
pub expected_config_revision: Revision,
pub id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub name: String,
pub base_url: String,
}
/// 修改 Provider instance 的公共字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProviderInstance {
pub expected_config_revision: Revision,
pub id: ProviderInstanceId,
pub name: String,
pub base_url: String,
}
/// 修改 Provider instance 的启用状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetProviderInstanceEnabled {
pub expected_config_revision: Revision,
pub id: ProviderInstanceId,
pub enabled: bool,
}
/// 删除 Provider instance。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteProviderInstance {
pub expected_config_revision: Revision,
pub id: ProviderInstanceId,
}
/// Provider instance 写入后的状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstanceMutation {
pub config_revision: Revision,
pub instance: Option<ProviderInstance>,
}
@@ -4,7 +4,6 @@ use std::num::{NonZeroU16, NonZeroU64};
pub mod accounts;
pub mod auth;
pub mod catalog;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
@@ -457,7 +457,6 @@ pub struct DashboardAccountModelUsage {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardAccountUsage {
pub account_id: String,
pub provider_instance_id: String,
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
@@ -506,7 +505,6 @@ pub struct UsageRecord {
pub client_transport: String,
pub requested_model_id: String,
pub input_token_estimate: u64,
pub provider_instance_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -579,7 +577,6 @@ pub struct UsageAttempt {
pub attempt_index: u32,
pub component: String,
pub operation: String,
pub provider_instance_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub upstream_model_id: Option<String>,
@@ -663,7 +660,6 @@ pub struct OpsError {
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub provider_instance_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub upstream_model_id: Option<String>,
@@ -5,7 +5,7 @@ use std::fmt;
use chrono::{DateTime, Utc};
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId},
routing::{ProviderInstanceId, ProviderKind, UpstreamModelId},
routing::{ProviderKind, UpstreamModelId},
};
use super::{
@@ -51,7 +51,6 @@ pub struct CredentialCursor {
/// Provider credential 列表查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialListQuery {
pub provider_instance_id: Option<ProviderInstanceId>,
pub availability: Option<CredentialAvailabilityFilter>,
pub enabled: Option<bool>,
pub window: CredentialListWindow,
@@ -103,7 +102,6 @@ pub struct CredentialDetails {
pub struct ImportCredentials {
pub context: MutationContext,
pub expected_config_revision: Revision,
pub provider_instance_id: ProviderInstanceId,
pub document: ProviderDocument,
}
@@ -113,7 +111,6 @@ impl fmt::Debug for ImportCredentials {
.debug_struct("ImportCredentials")
.field("context", &self.context)
.field("expected_config_revision", &self.expected_config_revision)
.field("provider_instance_id", &self.provider_instance_id)
.field("document", &self.document)
.finish()
}
@@ -126,9 +123,8 @@ pub struct CredentialImportResult {
pub credential_ids: Vec<ProviderAccountId>,
}
/// Provider 解析导入文档时只接收实例与不透明文档,不接触 revision 或审计上下文。
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
pub struct PrepareCredentialImport {
pub provider_instance_id: ProviderInstanceId,
pub document: ProviderDocument,
}
@@ -136,7 +132,6 @@ impl fmt::Debug for PrepareCredentialImport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PrepareCredentialImport")
.field("provider_instance_id", &self.provider_instance_id)
.field("document", &self.document)
.finish()
}
@@ -146,7 +141,6 @@ impl fmt::Debug for PrepareCredentialImport {
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub account_id: ProviderAccountId,
pub provider_instance_id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub name: String,
pub email: Option<String>,
@@ -168,7 +162,6 @@ pub struct PreparedCredentialCreate {
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialImport {
pub provider_kind: ProviderKind,
pub provider_instance_id: ProviderInstanceId,
pub credentials: Vec<PreparedCredentialCreate>,
}
@@ -249,11 +242,9 @@ impl fmt::Debug for AuthorizationOwnerBinding {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationMutationTarget {
Create {
provider_instance_id: ProviderInstanceId,
name: String,
},
Reauthorize {
provider_instance_id: ProviderInstanceId,
account_id: ProviderAccountId,
expected_credential_revision: Revision,
},
@@ -310,7 +301,6 @@ impl PendingAuthorizationMutation {
pub struct StartAuthorization {
pub context: MutationContext,
pub expected_config_revision: Revision,
pub provider_instance_id: ProviderInstanceId,
pub name: String,
pub reauthorization: Option<ReauthorizationTarget>,
}
@@ -427,6 +417,21 @@ pub struct CredentialMutationResult {
pub credential_revision: Option<Revision>,
}
/// 同一 Provider 管理范围内的 credential 批量删除。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDeletion {
pub context: MutationContext,
pub expected_config_revision: Revision,
pub account_ids: Vec<ProviderAccountId>,
}
/// Credential 批量删除提交结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDeletionResult {
pub config_revision: Revision,
pub account_ids: Vec<ProviderAccountId>,
}
/// Provider-owned token 轮换命令。
pub struct RotateCredential {
pub mutation: CredentialMutation,
@@ -459,7 +464,6 @@ impl fmt::Debug for PrepareCredentialRotation {
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialRotationFacts {
pub account_id: ProviderAccountId,
pub provider_instance_id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub expected_credential_revision: Revision,
pub name: String,
@@ -613,7 +617,6 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub provider_instance_name: String,
pub status: AccountStatus,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
@@ -9,15 +9,10 @@ use async_trait::async_trait;
use crate::model::{
MutationContext, Revision,
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccount,
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccounts,
SetAccountEnabled,
},
auth::{AdminAuditEvent, AdminSession},
catalog::{
CreateProviderInstance, DeleteProviderInstance, ProviderInstanceCatalog,
ProviderInstanceDetail, ProviderInstanceMutation, SetProviderInstanceEnabled,
UpdateProviderInstance,
},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -142,9 +137,9 @@ pub trait AccountStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn delete_account(
async fn delete_accounts(
&self,
command: DeleteAccount,
command: DeleteAccounts,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
@@ -194,44 +189,6 @@ pub trait AuthStore: Send + Sync {
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
}
/// Provider instance 目录与 revision CAS 写入。
#[async_trait]
pub trait CatalogStore: Send + Sync {
async fn list_provider_instances(
&self,
include_disabled: bool,
) -> AdminStoreResult<ProviderInstanceCatalog>;
async fn load_provider_instance(
&self,
id: &gateway_core::routing::ProviderInstanceId,
) -> AdminStoreResult<Option<ProviderInstanceDetail>>;
async fn create_provider_instance(
&self,
command: CreateProviderInstance,
context: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation>;
async fn update_provider_instance(
&self,
command: UpdateProviderInstance,
context: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation>;
async fn set_provider_instance_enabled(
&self,
command: SetProviderInstanceEnabled,
context: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation>;
async fn delete_provider_instance(
&self,
command: DeleteProviderInstance,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
}
/// Client API Key 管理及其 revision CAS 写入。
#[async_trait]
pub trait ClientKeyStore: Send + Sync {
@@ -334,7 +291,6 @@ pub trait SettingsStore: Send + Sync {
pub struct AdminStorePorts {
accounts: Arc<dyn AccountStore>,
auth: Arc<dyn AuthStore>,
catalog: Arc<dyn CatalogStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
@@ -345,7 +301,6 @@ impl AdminStorePorts {
pub fn new(
accounts: Arc<dyn AccountStore>,
auth: Arc<dyn AuthStore>,
catalog: Arc<dyn CatalogStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
@@ -353,7 +308,6 @@ impl AdminStorePorts {
Self {
accounts,
auth,
catalog,
client_keys,
observability,
settings,
@@ -370,11 +324,6 @@ impl AdminStorePorts {
self.auth.clone()
}
#[must_use]
pub fn catalog(&self) -> Arc<dyn CatalogStore> {
self.catalog.clone()
}
#[must_use]
pub fn client_keys(&self) -> Arc<dyn ClientKeyStore> {
self.client_keys.clone()
@@ -29,7 +29,7 @@ use crate::{
},
ports::{
provider::ProviderAdminRegistry,
store::{AccountStore, CatalogStore, SettingsStore},
store::{AccountStore, SettingsStore},
},
};
@@ -79,7 +79,6 @@ pub trait AccountsService: Send + Sync {
pub(crate) struct DefaultAccountsService {
accounts: Arc<dyn AccountStore>,
catalog: Arc<dyn CatalogStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
@@ -90,7 +89,6 @@ impl DefaultAccountsService {
#[must_use]
pub(crate) const fn new(
accounts: Arc<dyn AccountStore>,
catalog: Arc<dyn CatalogStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
@@ -98,7 +96,6 @@ impl DefaultAccountsService {
) -> Self {
Self {
accounts,
catalog,
settings,
providers,
snapshot,
@@ -146,17 +143,6 @@ impl DefaultAccountsService {
.load_runtime_settings()
.await
.map_err(|error| map_store_error(error, "runtime settings"))?;
let instance = self
.catalog
.load_provider_instance(&account.provider_instance_id)
.await
.map_err(|error| map_store_error(error, "Provider instance"))?
.ok_or_else(|| AdminError::not_found("Provider instance was not found"))?;
if instance.item.provider_kind != account.provider_kind {
return Err(AdminError::invalid(
"Provider account and instance kinds do not match",
));
}
let now = Utc::now();
let retained_range = retained_usage_range(now, settings.usage_retention_days);
let rolling_range = TimeRange {
@@ -188,7 +174,6 @@ impl DefaultAccountsService {
.await
.map_err(|error| map_provider_error(error, "provider quota"))?;
Ok(AccountDirectoryItem {
provider_instance_name: instance.item.name,
status: account_status(&account, now),
usage: usage.into_iter().next(),
account,
@@ -200,10 +185,9 @@ impl DefaultAccountsService {
#[async_trait]
impl AccountsService for DefaultAccountsService {
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
let (page, settings, instances) = futures::try_join!(
let (page, settings) = futures::try_join!(
self.accounts.list_accounts(query),
self.settings.load_runtime_settings(),
self.catalog.list_provider_instances(true),
)
.map_err(|error| map_store_error(error, "account directory"))?;
let now = Utc::now();
@@ -230,11 +214,6 @@ impl AccountsService for DefaultAccountsService {
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<BTreeMap<_, _>>();
let instances = instances
.items
.into_iter()
.map(|instance| (instance.id.clone(), instance))
.collect::<BTreeMap<_, _>>();
let quotas = futures::future::join_all(page.items.iter().map(|account| async {
let account_id = ProviderAccountId::new(account.id.clone())
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
@@ -258,24 +237,13 @@ impl AccountsService for DefaultAccountsService {
.items
.into_iter()
.zip(quotas)
.map(|(account, quota)| {
let instance = instances
.get(&account.provider_instance_id)
.ok_or_else(|| AdminError::not_found("Provider instance was not found"))?;
if instance.provider_kind != account.provider_kind {
return Err(AdminError::invalid(
"Provider account and instance kinds do not match",
));
}
Ok(AccountDirectoryItem {
provider_instance_name: instance.name.clone(),
status: account_status(&account, now),
usage: usage.get(&account.id).cloned(),
account,
quota,
})
.map(|(account, quota)| AccountDirectoryItem {
status: account_status(&account, now),
usage: usage.get(&account.id).cloned(),
account,
quota,
})
.collect::<Result<Vec<_>, AdminError>>()?;
.collect();
Ok(AccountDirectoryPage {
config_revision: page.config_revision,
items,
@@ -416,7 +384,7 @@ impl AccountsService for DefaultAccountsService {
let result = probe
.probe(AccountProbeRequest {
account_id,
provider_instance_id: account.provider_instance_id,
provider_kind: account.provider_kind,
upstream_model,
operation,
})
@@ -1,159 +0,0 @@
//! Provider instance 目录用例。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::routing::{ProviderInstanceId, snapshot::SnapshotControl};
use crate::{
model::{
AdminError, MutationContext, Revision,
catalog::{
CatalogListQuery, CreateProviderInstance, DeleteProviderInstance,
ProviderInstanceDetail, ProviderInstanceMutation, ProviderInstancePage,
SetProviderInstanceEnabled, UpdateProviderInstance,
},
},
ports::store::CatalogStore,
};
use super::{map_store_error, publish_committed};
/// API 消费的 Provider instance 管理服务。
#[async_trait]
pub trait CatalogService: Send + Sync {
async fn list(&self, query: CatalogListQuery) -> Result<ProviderInstancePage, AdminError>;
async fn get(&self, id: &ProviderInstanceId) -> Result<ProviderInstanceDetail, AdminError>;
async fn create(
&self,
context: &MutationContext,
command: CreateProviderInstance,
) -> Result<ProviderInstanceMutation, AdminError>;
async fn update(
&self,
context: &MutationContext,
command: UpdateProviderInstance,
) -> Result<ProviderInstanceMutation, AdminError>;
async fn set_enabled(
&self,
context: &MutationContext,
command: SetProviderInstanceEnabled,
) -> Result<ProviderInstanceMutation, AdminError>;
async fn delete(
&self,
context: &MutationContext,
command: DeleteProviderInstance,
) -> Result<Revision, AdminError>;
}
pub(crate) struct DefaultCatalogService {
store: Arc<dyn CatalogStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultCatalogService {
#[must_use]
pub(crate) fn new(store: Arc<dyn CatalogStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
Self { store, snapshot }
}
}
#[async_trait]
impl CatalogService for DefaultCatalogService {
async fn list(&self, query: CatalogListQuery) -> Result<ProviderInstancePage, AdminError> {
let catalog = self
.store
.list_provider_instances(true)
.await
.map_err(|error| map_store_error(error, "provider instance catalog"))?;
let start = match query.cursor.as_ref() {
Some(cursor) => catalog
.items
.iter()
.position(|item| &item.id == cursor)
.map(|index| index + 1)
.ok_or_else(|| AdminError::invalid("Invalid provider instance cursor"))?,
None => 0,
};
let limit = usize::from(query.page_size.get());
let has_more = catalog.items.len().saturating_sub(start) > limit;
let items = catalog
.items
.into_iter()
.skip(start)
.take(limit)
.collect::<Vec<_>>();
let next_cursor = has_more
.then(|| items.last().map(|item| item.id.clone()))
.flatten();
Ok(ProviderInstancePage {
config_revision: catalog.config_revision,
items,
next_cursor,
})
}
async fn get(&self, id: &ProviderInstanceId) -> Result<ProviderInstanceDetail, AdminError> {
self.store
.load_provider_instance(id)
.await
.map_err(|error| map_store_error(error, "provider instance"))?
.ok_or_else(|| AdminError::not_found("Provider instance was not found"))
}
async fn create(
&self,
context: &MutationContext,
command: CreateProviderInstance,
) -> Result<ProviderInstanceMutation, AdminError> {
let result = self
.store
.create_provider_instance(command, context)
.await
.map_err(|error| map_store_error(error, "provider instance"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn update(
&self,
context: &MutationContext,
command: UpdateProviderInstance,
) -> Result<ProviderInstanceMutation, AdminError> {
let result = self
.store
.update_provider_instance(command, context)
.await
.map_err(|error| map_store_error(error, "provider instance"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn set_enabled(
&self,
context: &MutationContext,
command: SetProviderInstanceEnabled,
) -> Result<ProviderInstanceMutation, AdminError> {
let result = self
.store
.set_provider_instance_enabled(command, context)
.await
.map_err(|error| map_store_error(error, "provider instance"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn delete(
&self,
context: &MutationContext,
command: DeleteProviderInstance,
) -> Result<Revision, AdminError> {
let revision = self
.store
.delete_provider_instance(command, context)
.await
.map_err(|error| map_store_error(error, "provider instance"))?;
publish_committed(self.snapshot.as_ref(), revision).await?;
Ok(revision)
}
}
@@ -2,7 +2,6 @@
pub mod accounts;
pub mod auth;
pub mod catalog;
pub mod client_keys;
pub mod observability;
pub mod openai;
@@ -13,12 +12,13 @@ pub mod xai;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext,
accounts::{DeleteAccount, SetAccountEnabled},
accounts::{DeleteAccounts, SetAccountEnabled},
provider_credentials::{
AuthorizationMutationTarget, CredentialDetails, CredentialMutation,
CredentialMutationResult, CredentialRotationCommit, PendingAuthorizationMutation,
PreparedAuthorizationCommit, PreparedAuthorizationCredential, PreparedCredentialImport,
PreparedCredentialRotation, StartAuthorization,
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
CredentialDetails, CredentialMutation, CredentialMutationResult,
CredentialRotationCommit, PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
StartAuthorization,
},
},
ports::{
@@ -93,21 +93,17 @@ async fn pending_authorization(
Some(target) => {
let details =
required_credential(accounts, provider_kind, &target.account_id, resource).await?;
if details.credential.provider_instance_id != command.provider_instance_id
|| details.credential.credential_revision != target.credential_revision
{
if details.credential.credential_revision != target.credential_revision {
return Err(AdminError::conflict(format!(
"{resource} reauthorization target is stale"
)));
}
AuthorizationMutationTarget::Reauthorize {
provider_instance_id: command.provider_instance_id.clone(),
account_id: target.account_id.clone(),
expected_credential_revision: target.credential_revision,
}
}
None => AuthorizationMutationTarget::Create {
provider_instance_id: command.provider_instance_id.clone(),
name: command.name.clone(),
},
};
@@ -123,16 +119,14 @@ async fn pending_authorization(
fn validate_prepared_import(
provider_kind: &ProviderKind,
provider_instance_id: &gateway_core::routing::ProviderInstanceId,
prepared: &PreparedCredentialImport,
resource: &'static str,
) -> Result<(), AdminError> {
if prepared.provider_kind != *provider_kind
|| prepared.provider_instance_id != *provider_instance_id
|| prepared.credentials.iter().any(|credential| {
credential.provider_kind != *provider_kind
|| credential.provider_instance_id != *provider_instance_id
})
|| prepared
.credentials
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict(format!(
"{resource} prepared facts do not match the requested Provider scope"
@@ -149,7 +143,6 @@ fn validate_prepared_rotation(
let facts = prepared.facts();
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
|| facts.provider_instance_id != account.provider_instance_id
|| facts.expected_credential_revision != account.credential_revision
{
return Err(AdminError::conflict(format!(
@@ -174,18 +167,11 @@ fn validate_authorization_commit(
}
let matches_target = match (prepared.pending.target(), &prepared.credential) {
(
AuthorizationMutationTarget::Create {
provider_instance_id,
..
},
AuthorizationMutationTarget::Create { .. },
PreparedAuthorizationCredential::Create(credential),
) => {
credential.provider_kind == *provider_kind
&& credential.provider_instance_id == *provider_instance_id
}
) => credential.provider_kind == *provider_kind,
(
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
},
@@ -193,7 +179,6 @@ fn validate_authorization_commit(
) => {
let facts = credential.facts();
facts.provider_kind == *provider_kind
&& facts.provider_instance_id == *provider_instance_id
&& facts.account_id == *account_id
&& facts.expected_credential_revision == *expected_credential_revision
}
@@ -322,34 +307,34 @@ async fn set_credential_enabled(
})
}
async fn delete_credential(
async fn delete_credentials(
accounts: &dyn AccountStore,
provider: &dyn ProviderAdmin,
command: CredentialMutation,
command: CredentialDeletion,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
required_credential(
accounts,
provider.provider_kind(),
&command.account_id,
resource,
)
.await?;
let account_id = command.account_id;
) -> Result<CredentialDeletionResult, AdminError> {
for account_id in &command.account_ids {
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
}
let account_ids = command.account_ids;
let revision = accounts
.delete_account(
DeleteAccount {
.delete_accounts(
DeleteAccounts {
expected_config_revision: command.expected_config_revision,
account_id: account_id.as_str().to_owned(),
account_ids: account_ids
.iter()
.map(|account_id| account_id.as_str().to_owned())
.collect(),
},
&command.context,
)
.await
.map_err(|error| map_store_error(error, resource))?;
provider.account_unavailable(&account_id).await;
Ok(CredentialMutationResult {
for account_id in &account_ids {
provider.account_unavailable(account_id).await;
}
Ok(CredentialDeletionResult {
config_revision: revision,
account_id,
credential_revision: None,
account_ids,
})
}
@@ -9,7 +9,8 @@ use crate::{
model::{
AdminError,
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDetails, CredentialImportCommit,
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
CredentialImportResult, CredentialListQuery, CredentialMutation,
CredentialMutationResult, CredentialPage, ImportCredentials, PrepareCredentialImport,
PrepareCredentialRotation, RotateCredential, StartAuthorization,
@@ -19,7 +20,7 @@ use crate::{
};
use super::{
commit_authorization, commit_credential_rotation, delete_credential, map_provider_error,
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
map_store_error, pending_authorization, publish_committed, required_credential,
set_credential_enabled, validate_authorization_commit, validate_prepared_import,
validate_prepared_rotation,
@@ -59,8 +60,8 @@ pub trait OpenAiService: Send + Sync {
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError>;
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultOpenAiService {
@@ -112,18 +113,15 @@ impl OpenAiService for DefaultOpenAiService {
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let expected_config_revision = command.expected_config_revision;
let provider_instance_id = command.provider_instance_id;
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
provider_instance_id: provider_instance_id.clone(),
document: command.document,
})
.await
.map_err(|error| map_provider_error(error, "OpenAI credential import"))?;
validate_prepared_import(
self.provider.provider_kind(),
&provider_instance_id,
&prepared,
"OpenAI credential import",
)?;
@@ -262,9 +260,9 @@ impl OpenAiService for DefaultOpenAiService {
async fn delete(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError> {
let result = delete_credential(
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError> {
let result = delete_credentials(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
@@ -9,17 +9,17 @@ use crate::{
model::{
AdminError,
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialImportCommit,
CredentialImportResult, CredentialListQuery, CredentialMutation,
CredentialMutationResult, CredentialPage, ImportCredentials, PrepareCredentialImport,
StartAuthorization,
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
CredentialListQuery, CredentialMutation, CredentialMutationResult, CredentialPage,
ImportCredentials, PrepareCredentialImport, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
};
use super::{
commit_authorization, delete_credential, map_provider_error, map_store_error,
commit_authorization, delete_credentials, map_provider_error, map_store_error,
pending_authorization, publish_committed, set_credential_enabled,
validate_authorization_commit, validate_prepared_import,
};
@@ -50,8 +50,8 @@ pub trait XaiService: Send + Sync {
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError>;
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultXaiService {
@@ -90,18 +90,15 @@ impl XaiService for DefaultXaiService {
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let expected_config_revision = command.expected_config_revision;
let provider_instance_id = command.provider_instance_id;
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
provider_instance_id: provider_instance_id.clone(),
document: command.document,
})
.await
.map_err(|error| map_provider_error(error, "xAI credential import"))?;
validate_prepared_import(
self.provider.provider_kind(),
&provider_instance_id,
&prepared,
"xAI credential import",
)?;
@@ -198,9 +195,9 @@ impl XaiService for DefaultXaiService {
async fn delete(
&self,
command: CredentialMutation,
) -> Result<CredentialMutationResult, AdminError> {
let result = delete_credential(
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError> {
let result = delete_credentials(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
+1 -4
View File
@@ -12,9 +12,7 @@ use gateway_admin::{
provider_credentials::{CredentialCommitGuard, ProviderDocument},
settings::AdminApiKey,
},
ports::store::{
AccountStore, AuthStore, CatalogStore, ClientKeyStore, ObservabilityStore, SettingsStore,
},
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
};
mod use_case;
@@ -98,7 +96,6 @@ fn all_store_capability_traits_should_be_object_safe() {
assert_object_safe::<dyn AccountStore>();
assert_object_safe::<dyn AuthStore>();
assert_object_safe::<dyn CatalogStore>();
assert_object_safe::<dyn ClientKeyStore>();
assert_object_safe::<dyn ObservabilityStore>();
assert_object_safe::<dyn SettingsStore>();
@@ -9,7 +9,7 @@ use gateway_core::{
probe::{AccountProbe, AccountProbeRequest, AccountProbeResult},
},
error::GatewayError,
routing::{ProviderInstanceId, ProviderKind},
routing::ProviderKind,
};
use gateway_admin::{
@@ -18,12 +18,7 @@ use gateway_admin::{
MutationContext, Revision,
accounts::{
AccountAvailability, AccountListQuery, AccountPage, AccountRecord, AccountSummary,
AccountUsage, DeleteAccount, SetAccountEnabled,
},
catalog::{
CreateProviderInstance, DeleteProviderInstance, ProviderInstance,
ProviderInstanceCatalog, ProviderInstanceDetail, ProviderInstanceMutation,
SetProviderInstanceEnabled, UpdateProviderInstance,
AccountUsage, DeleteAccounts, SetAccountEnabled,
},
observability::TimeRange,
provider_credentials::{
@@ -47,8 +42,7 @@ use gateway_admin::{
ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry,
},
store::{
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStoreResult, CatalogStore,
SettingsStore,
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStoreResult, SettingsStore,
},
},
};
@@ -101,7 +95,6 @@ impl FakeProviderAdmin {
PreparedCredentialRotation::new(
PreparedCredentialRotationFacts {
account_id: ProviderAccountId::new(account.id.clone()).expect("account ID"),
provider_instance_id: account.provider_instance_id.clone(),
provider_kind: account.provider_kind.clone(),
expected_credential_revision: account.credential_revision,
name: account.name.clone(),
@@ -153,18 +146,13 @@ impl ProviderAdmin for FakeProviderAdmin {
async fn prepare_import(
&self,
command: PrepareCredentialImport,
_command: PrepareCredentialImport,
) -> Result<PreparedCredentialImport, ProviderAdminError> {
self.record("provider.prepare_import");
self.require_available()?;
Ok(PreparedCredentialImport {
provider_kind: self.kind.clone(),
provider_instance_id: command.provider_instance_id.clone(),
credentials: vec![prepared_create(
self.kind.clone(),
command.provider_instance_id,
"prepared-import",
)],
credentials: vec![prepared_create(self.kind.clone(), "prepared-import")],
})
}
@@ -198,22 +186,15 @@ impl ProviderAdmin for FakeProviderAdmin {
return Err(ProviderAdminError::new(ProviderAdminErrorKind::NotFound));
}
let credential = match pending.target() {
AuthorizationMutationTarget::Create {
provider_instance_id,
name,
} => PreparedAuthorizationCredential::Create(prepared_create(
self.kind.clone(),
provider_instance_id.clone(),
name,
)),
AuthorizationMutationTarget::Create { name } => {
PreparedAuthorizationCredential::Create(prepared_create(self.kind.clone(), name))
}
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} => {
let mut account = account_record(self.kind.as_str());
account.id = account_id.as_str().to_owned();
account.provider_instance_id = provider_instance_id.clone();
account.credential_revision = *expected_credential_revision;
PreparedAuthorizationCredential::Reauthorize(self.prepared_rotation(&account))
}
@@ -502,9 +483,9 @@ impl AccountStore for FakeAccountStore {
Ok(revision(2))
}
async fn delete_account(
async fn delete_accounts(
&self,
_: DeleteAccount,
_: DeleteAccounts,
context: &MutationContext,
) -> AdminStoreResult<Revision> {
self.record("store.delete");
@@ -574,62 +555,6 @@ impl SettingsStore for StaticSettingsStore {
}
}
struct StaticCatalogStore {
instance: ProviderInstance,
}
#[async_trait]
impl CatalogStore for StaticCatalogStore {
async fn list_provider_instances(&self, _: bool) -> AdminStoreResult<ProviderInstanceCatalog> {
Ok(ProviderInstanceCatalog {
config_revision: revision(1),
items: vec![self.instance.clone()],
})
}
async fn load_provider_instance(
&self,
id: &ProviderInstanceId,
) -> AdminStoreResult<Option<ProviderInstanceDetail>> {
Ok((id == &self.instance.id).then(|| ProviderInstanceDetail {
config_revision: revision(1),
item: self.instance.clone(),
}))
}
async fn create_provider_instance(
&self,
_: CreateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(store_unavailable())
}
async fn update_provider_instance(
&self,
_: UpdateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(store_unavailable())
}
async fn set_provider_instance_enabled(
&self,
_: SetProviderInstanceEnabled,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(store_unavailable())
}
async fn delete_provider_instance(
&self,
_: DeleteProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(store_unavailable())
}
}
struct RecordingGuard {
events: EventLog,
finished: bool,
@@ -731,7 +656,7 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
.await
.expect("refresh credential");
assert_eq!(result.config_revision, revision(2));
assert_eq!(result.account.provider_instance_name, "Test Provider");
assert_eq!(result.account.account.provider_kind.as_str(), "openai");
assert_eq!(
result.account.status,
gateway_admin::model::accounts::AccountStatus::Active
@@ -771,7 +696,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
assert_eq!(page.summary.total, 1);
assert_eq!(page.summary.active, 1);
let account = page.items.first().expect("account item");
assert_eq!(account.provider_instance_name, "Test Provider");
assert_eq!(account.account.provider_kind.as_str(), "openai");
assert_eq!(
account.status,
gateway_admin::model::accounts::AccountStatus::Active
@@ -856,7 +781,6 @@ pub(super) fn account_record(kind: &str) -> AccountRecord {
let now = Utc::now();
AccountRecord {
id: "acct_test".to_owned(),
provider_instance_id: ProviderInstanceId::new(format!("inst_{kind}")).expect("instance ID"),
provider_kind: ProviderKind::new(kind).expect("provider kind"),
name: "test account".to_owned(),
email: Some("test@example.invalid".to_owned()),
@@ -882,15 +806,10 @@ pub(super) fn revision(value: u64) -> Revision {
Revision::new(value).expect("positive revision")
}
fn prepared_create(
provider_kind: ProviderKind,
provider_instance_id: ProviderInstanceId,
name: &str,
) -> PreparedCredentialCreate {
fn prepared_create(provider_kind: ProviderKind, name: &str) -> PreparedCredentialCreate {
let now = Utc::now();
PreparedCredentialCreate {
account_id: ProviderAccountId::new("acct_prepared").expect("prepared account ID"),
provider_instance_id,
provider_kind,
name: name.to_owned(),
email: Some("prepared@example.invalid".to_owned()),
@@ -923,19 +842,8 @@ async fn accounts_service(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
) -> AdminServices {
let now = Utc::now();
let instance = ProviderInstance {
id: store.account.provider_instance_id.clone(),
provider_kind: store.account.provider_kind.clone(),
name: "Test Provider".to_owned(),
base_url: "https://example.invalid".to_owned(),
enabled: true,
created_at: now,
updated_at: now,
};
super::AdminHarness::new()
.accounts(store)
.catalog(Arc::new(StaticCatalogStore { instance }))
.settings(Arc::new(StaticSettingsStore))
.provider(provider)
.probe(Arc::new(SuccessfulAccountProbe))
@@ -1,144 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use gateway_admin::{
model::{
MutationContext, PageSize, Revision,
catalog::{
CatalogListQuery, CreateProviderInstance, DeleteProviderInstance, ProviderInstance,
ProviderInstanceCatalog, ProviderInstanceDetail, ProviderInstanceMutation,
SetProviderInstanceEnabled, UpdateProviderInstance,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, CatalogStore},
};
struct CatalogFixtureStore {
items: Vec<ProviderInstance>,
}
#[async_trait]
impl CatalogStore for CatalogFixtureStore {
async fn list_provider_instances(&self, _: bool) -> AdminStoreResult<ProviderInstanceCatalog> {
Ok(ProviderInstanceCatalog {
config_revision: Revision::new(7).expect("revision"),
items: self.items.clone(),
})
}
async fn load_provider_instance(
&self,
id: &ProviderInstanceId,
) -> AdminStoreResult<Option<ProviderInstanceDetail>> {
Ok(self
.items
.iter()
.find(|item| &item.id == id)
.cloned()
.map(|item| ProviderInstanceDetail {
config_revision: Revision::new(7).expect("revision"),
item,
}))
}
async fn create_provider_instance(
&self,
_: CreateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unused())
}
async fn update_provider_instance(
&self,
_: UpdateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unused())
}
async fn set_provider_instance_enabled(
&self,
_: SetProviderInstanceEnabled,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unused())
}
async fn delete_provider_instance(
&self,
_: DeleteProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unused())
}
}
#[tokio::test]
async fn catalog_cursor_should_resume_after_exact_instance() {
let items = ["inst_a", "inst_b", "inst_c"]
.into_iter()
.map(instance)
.collect();
let services = super::AdminHarness::new()
.catalog(Arc::new(CatalogFixtureStore { items }))
.build()
.await;
let page = services
.catalog()
.list(CatalogListQuery {
cursor: Some(ProviderInstanceId::new("inst_a").expect("cursor")),
page_size: PageSize::new(1).expect("page size"),
})
.await
.expect("catalog page");
assert_eq!(page.items[0].id.as_str(), "inst_b");
assert_eq!(
page.next_cursor.as_ref().map(ProviderInstanceId::as_str),
Some("inst_b")
);
}
#[tokio::test]
async fn catalog_detail_should_keep_its_control_plane_revision() {
let services = super::AdminHarness::new()
.catalog(Arc::new(CatalogFixtureStore {
items: vec![instance("inst_detail")],
}))
.build()
.await;
let detail = services
.catalog()
.get(&ProviderInstanceId::new("inst_detail").expect("instance ID"))
.await
.expect("catalog detail");
assert_eq!(detail.config_revision.get(), 7);
assert_eq!(detail.item.id.as_str(), "inst_detail");
}
fn instance(id: &str) -> ProviderInstance {
ProviderInstance {
id: ProviderInstanceId::new(id).expect("instance ID"),
provider_kind: ProviderKind::new("openai").expect("provider"),
name: id.to_owned(),
base_url: "https://example.com".to_owned(),
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn unused() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"catalog",
"unused in this test",
)
}
@@ -1,6 +1,5 @@
mod accounts;
mod auth;
mod catalog;
mod client_keys;
mod observability;
mod openai;
@@ -17,15 +16,10 @@ use gateway_admin::{
model::{
MutationContext, Revision,
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccount,
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccounts,
SetAccountEnabled,
},
auth::{AdminAuditEvent, AdminSession},
catalog::{
CreateProviderInstance, DeleteProviderInstance, ProviderInstanceCatalog,
ProviderInstanceDetail, ProviderInstanceMutation, SetProviderInstanceEnabled,
UpdateProviderInstance,
},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -52,7 +46,7 @@ use gateway_admin::{
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
AuthStore, CatalogStore, ClientKeyStore, ObservabilityStore, SettingsStore,
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
},
system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations,
@@ -67,14 +61,13 @@ use gateway_core::{
},
error::{GatewayError, GatewayErrorKind},
policy::ClientApiKeyId,
routing::{ConfigRevision, ProviderInstanceId, ProviderKind, snapshot::SnapshotControl},
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
};
pub(super) struct AdminHarness {
default_password: String,
accounts: Arc<dyn AccountStore>,
auth: Arc<dyn AuthStore>,
catalog: Arc<dyn CatalogStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
@@ -90,7 +83,6 @@ impl AdminHarness {
default_password: "strong-test-password".to_owned(),
accounts: unavailable.clone(),
auth: Arc::new(BootstrapAuthStore::default()),
catalog: unavailable.clone(),
client_keys: unavailable.clone(),
observability: unavailable.clone(),
settings: unavailable,
@@ -118,11 +110,6 @@ impl AdminHarness {
self
}
pub(super) fn catalog(mut self, store: Arc<dyn CatalogStore>) -> Self {
self.catalog = store;
self
}
pub(super) fn client_keys(mut self, store: Arc<dyn ClientKeyStore>) -> Self {
self.client_keys = store;
self
@@ -165,7 +152,6 @@ impl AdminHarness {
AdminStorePorts::new(
self.accounts,
self.auth,
self.catalog,
self.client_keys,
self.observability,
self.settings,
@@ -322,9 +308,9 @@ impl AccountStore for UnavailableStore {
Err(unavailable("account enabled"))
}
async fn delete_account(
async fn delete_accounts(
&self,
_: DeleteAccount,
_: DeleteAccounts,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("account delete"))
@@ -339,52 +325,6 @@ impl AccountStore for UnavailableStore {
}
}
#[async_trait]
impl CatalogStore for UnavailableStore {
async fn list_provider_instances(&self, _: bool) -> AdminStoreResult<ProviderInstanceCatalog> {
Err(unavailable("provider catalog"))
}
async fn load_provider_instance(
&self,
_: &ProviderInstanceId,
) -> AdminStoreResult<Option<ProviderInstanceDetail>> {
Err(unavailable("provider instance"))
}
async fn create_provider_instance(
&self,
_: CreateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unavailable("provider create"))
}
async fn update_provider_instance(
&self,
_: UpdateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unavailable("provider update"))
}
async fn set_provider_instance_enabled(
&self,
_: SetProviderInstanceEnabled,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unavailable("provider enabled"))
}
async fn delete_provider_instance(
&self,
_: DeleteProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("provider delete"))
}
}
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
@@ -1,13 +1,12 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::routing::ProviderInstanceId;
use gateway_admin::{
AdminServices,
model::provider_credentials::{
AuthorizationMutationTarget, CompleteAuthorization, CredentialMutation, ImportCredentials,
ReauthorizationTarget, StartAuthorization,
AuthorizationMutationTarget, CompleteAuthorization, CredentialDeletion, CredentialMutation,
ImportCredentials, ReauthorizationTarget, StartAuthorization,
},
ports::provider::ProviderAdminErrorKind,
};
@@ -69,7 +68,7 @@ async fn openai_delete_should_commit_then_release_provider_resources() {
services
.openai()
.delete(mutation("acct_test"))
.delete(deletion("acct_test"))
.await
.expect("delete credential");
@@ -116,7 +115,6 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
.import_document(ImportCredentials {
context: context("import-openai"),
expected_config_revision: revision(1),
provider_instance_id: instance(),
document: document(),
})
.await
@@ -142,7 +140,6 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
.import_document(ImportCredentials {
context: context("import-openai-error"),
expected_config_revision: revision(1),
provider_instance_id: instance(),
document: document(),
})
.await
@@ -162,7 +159,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
.start_authorization(StartAuthorization {
context: context("oauth-start-openai"),
expected_config_revision: revision(7),
provider_instance_id: instance(),
name: "reauthorize".to_owned(),
reauthorization: Some(ReauthorizationTarget {
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
@@ -182,11 +178,9 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
assert!(matches!(
pending.target(),
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} if provider_instance_id == &instance()
&& account_id.as_str() == "acct_test"
} if account_id.as_str() == "acct_test"
&& *expected_credential_revision == revision(1)
));
assert!(!format!("{pending:?}").contains("admin-test"));
@@ -231,6 +225,10 @@ fn mutation(account_id: &str) -> CredentialMutation {
}
}
fn instance() -> ProviderInstanceId {
ProviderInstanceId::new("inst_openai").expect("instance ID")
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")],
}
}
@@ -1,6 +1,6 @@
use std::sync::Arc;
use gateway_core::{engine::credential::ProviderAccountId, routing::ProviderInstanceId};
use gateway_core::engine::credential::ProviderAccountId;
use gateway_admin::{
AdminServices,
@@ -47,7 +47,6 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
.import_document(ImportCredentials {
context: context("import-xai"),
expected_config_revision: revision(3),
provider_instance_id: ProviderInstanceId::new("inst_xai").expect("instance ID"),
document: document(),
})
.await
+741 -32
View File
@@ -12,6 +12,7 @@ use axum::{
},
routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, FixedOffset, Utc};
use futures::{Stream, StreamExt as _};
use gateway_admin::model::{
@@ -23,15 +24,18 @@ use gateway_admin::model::{
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AuthorizationStarted, CompleteAuthorization, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderQuota, ProviderQuotaWindow,
ReauthorizationTarget, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
engine::credential::ProviderAccountId,
engine::credential::{OpaqueProviderData, ProviderAccountId},
routing::{ProviderKind, UpstreamModelId},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{Map, Value};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
@@ -41,6 +45,13 @@ use super::{
const DEFAULT_PAGE_SIZE: u32 = 50;
const MAX_PAGE_SIZE: u32 = 200;
const MAX_SEARCH_BYTES: usize = 256;
const MAX_ID_BYTES: usize = 256;
const MAX_NAME_BYTES: usize = 512;
const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024;
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
@@ -173,8 +184,6 @@ pub struct AccountView {
pub id: String,
pub name: String,
pub provider: String,
pub provider_instance_id: String,
pub provider_instance_name: String,
pub resource_ref: String,
pub email: Option<String>,
pub account_id: Option<String>,
@@ -307,27 +316,27 @@ pub struct CurrencyCostView {
/// 账号详情类 GET 的固定 ID query。
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountIdQuery {
pub id: String,
pub account_id: String,
}
impl AccountIdQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.id)
require_account_id(&self.account_id, "accountId")
}
fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.id).map_err(|_| WireValidationError::new("id"))
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 敏感导出的固定 query;IDs 使用逗号分隔,禁止隐式导出全部账号。
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountExportQuery {
pub ids: String,
pub account_ids: String,
pub confirm: String,
}
@@ -337,41 +346,47 @@ impl AccountExportQuery {
return Err(WireValidationError::new("confirm"));
}
let ids = self
.ids
.account_ids
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if ids.is_empty() || ids.len() > 200 || ids.iter().any(|id| require_account_id(id).is_err())
if ids.is_empty()
|| ids.len() > 200
|| ids
.iter()
.any(|id| require_account_id(id, "accountIds").is_err())
{
return Err(WireValidationError::new("ids"));
return Err(WireValidationError::new("accountIds"));
}
let unique = ids.iter().collect::<BTreeSet<_>>();
if unique.len() != ids.len() {
return Err(WireValidationError::new("ids"));
return Err(WireValidationError::new("accountIds"));
}
ids.into_iter()
.map(|id| ProviderAccountId::new(id).map_err(|_| WireValidationError::new("ids")))
.map(|id| {
ProviderAccountId::new(id).map_err(|_| WireValidationError::new("accountIds"))
})
.collect()
}
}
/// 账号运行期动作。
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountActionRequest {
pub id: String,
pub account_id: String,
}
impl AccountActionRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.id)
require_account_id(&self.account_id, "accountId")
}
fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.id).map_err(|_| WireValidationError::new("id"))
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
@@ -379,13 +394,13 @@ impl AccountActionRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountRefreshRequest {
pub id: String,
pub account_id: String,
pub expected_config_revision: u64,
}
impl AccountRefreshRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.id)?;
require_account_id(&self.account_id, "accountId")?;
if self.expected_config_revision == 0 {
return Err(WireValidationError::new("expectedConfigRevision"));
}
@@ -397,7 +412,8 @@ impl AccountRefreshRequest {
Ok((
Revision::new(self.expected_config_revision)
.map_err(|_| WireValidationError::new("expectedConfigRevision"))?,
ProviderAccountId::new(self.id).map_err(|_| WireValidationError::new("id"))?,
ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
))
}
}
@@ -406,13 +422,13 @@ impl AccountRefreshRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountTestQuery {
pub id: String,
pub account_id: String,
pub model_id: String,
}
impl AccountTestQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.id)?;
require_account_id(&self.account_id, "accountId")?;
if self.model_id.trim().is_empty() || self.model_id.chars().any(char::is_control) {
return Err(WireValidationError::new("modelId"));
}
@@ -422,7 +438,8 @@ impl AccountTestQuery {
fn into_command(self) -> Result<(ProviderAccountId, UpstreamModelId), WireValidationError> {
self.validate()?;
Ok((
ProviderAccountId::new(self.id).map_err(|_| WireValidationError::new("id"))?,
ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
UpstreamModelId::new(self.model_id).map_err(|_| WireValidationError::new("modelId"))?,
))
}
@@ -536,26 +553,413 @@ impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
}
}
fn require_account_id(value: &str) -> Result<(), WireValidationError> {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AccountProvider {
OpenAi,
Xai,
}
impl AccountProvider {
fn parse(value: &str) -> Result<Self, WireValidationError> {
match value.trim() {
"openai" => Ok(Self::OpenAi),
"xai" => Ok(Self::Xai),
_ => Err(WireValidationError::new("provider")),
}
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[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)
{
return Err(WireValidationError::new("data"));
}
Ok(())
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, ImportCredentials), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
ImportCredentials {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
document: provider_document(self.data, "data")?,
},
))
}
}
#[derive(Debug, Clone, Deserialize)]
#[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>,
}
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(),
self.expected_credential_revision,
) {
(None, None) => Ok(()),
(Some(account_id), Some(credential_revision)) => {
require_account_id(account_id, "accountId")?;
require_positive(credential_revision, "expectedCredentialRevision")
}
_ => Err(WireValidationError::new("reauthorization")),
}
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, StartAuthorization), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
let reauthorization = match (self.account_id, self.expected_credential_revision) {
(None, None) => None,
(Some(account_id), Some(credential_revision)) => Some(ReauthorizationTarget {
account_id: ProviderAccountId::new(account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
credential_revision: revision(credential_revision, "expectedCredentialRevision")?,
}),
_ => return Err(WireValidationError::new("reauthorization")),
};
Ok((
provider,
StartAuthorization {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
name: self.name,
reauthorization,
},
))
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteAccountAuthorizationRequest {
pub provider: String,
pub flow_id: String,
pub callback_url: String,
}
impl CompleteAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
let provider = AccountProvider::parse(&self.provider)?;
match provider {
AccountProvider::OpenAi => {
if !URL_SAFE_NO_PAD
.decode(&self.flow_id)
.is_ok_and(|decoded| decoded.len() == 32)
{
return Err(WireValidationError::new("flowId"));
}
}
AccountProvider::Xai => require_wire_id(&self.flow_id, "flowId")?,
}
require_text(&self.callback_url, MAX_CALLBACK_URL_BYTES, "callbackUrl")
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, CompleteAuthorization), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
CompleteAuthorization {
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
},
))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
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")
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, CredentialMutation), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
CredentialMutation {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
},
))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountDeletionRequest {
pub provider: String,
pub account_ids: Vec<String>,
pub expected_config_revision: u64,
}
impl AccountDeletionRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
if self.account_ids.is_empty() || self.account_ids.len() > MAX_ACCOUNT_DELETE_BATCH {
return Err(WireValidationError::new("accountIds"));
}
let mut unique = BTreeSet::new();
for account_id in &self.account_ids {
require_account_id(account_id, "accountIds")?;
if !unique.insert(account_id.as_str()) {
return Err(WireValidationError::new("accountIds"));
}
}
require_positive(self.expected_config_revision, "expectedConfigRevision")
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, CredentialDeletion), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
CredentialDeletion {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_ids: self
.account_ids
.into_iter()
.map(ProviderAccountId::new)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| WireValidationError::new("accountIds"))?,
},
))
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
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>,
}
impl RotateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if AccountProvider::parse(&self.provider)? != AccountProvider::OpenAi {
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",
)?;
validate_oauth_material(&self.access_token, self.refresh_token.as_deref())
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<RotateCredential, WireValidationError> {
self.validate()?;
let mut material = Map::new();
material.insert("access_token".to_owned(), Value::String(self.access_token));
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
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"))?,
},
expected_credential_revision: revision(
self.expected_credential_revision,
"expectedCredentialRevision",
)?,
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
})
}
}
#[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>,
}
impl From<CredentialImportResult> for AccountImportData {
fn from(result: CredentialImportResult) -> Self {
let account_ids = result
.credential_ids
.into_iter()
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
config_revision: result.config_revision.get(),
imported_count: account_ids.len(),
account_ids,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountAuthorizationData {
pub flow_id: String,
pub authorization_url: String,
pub expires_at: DateTime<Utc>,
}
impl From<AuthorizationStarted> for AccountAuthorizationData {
fn from(started: AuthorizationStarted) -> Self {
Self {
flow_id: started.flow_id,
authorization_url: started.authorization_url,
expires_at: started.expires_at,
}
}
}
#[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>,
}
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),
}
}
}
#[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>,
}
impl From<CredentialDeletionResult> for AccountDeletionData {
fn from(result: CredentialDeletionResult) -> Self {
let account_ids = result
.account_ids
.into_iter()
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
config_revision: result.config_revision.get(),
deleted_count: account_ids.len(),
account_ids,
}
}
}
fn require_account_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
if value.trim().is_empty()
|| value.len() > 128
|| value.chars().any(char::is_control)
|| !value.starts_with("acct_")
{
return Err(WireValidationError::new("id"));
return Err(WireValidationError::new(field));
}
Ok(())
}
/// 构造固定 GET 账号目录路由。
/// 构造统一账号管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/accounts", get(list_accounts::<S>))
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
.route("/api/admin/accounts/import", post(import_accounts::<S>))
.route("/api/admin/accounts/refresh", post(refresh_account::<S>))
.route("/api/admin/accounts/rotate", post(rotate_account::<S>))
.route("/api/admin/accounts/enable", post(enable_account::<S>))
.route("/api/admin/accounts/disable", post(disable_account::<S>))
.route("/api/admin/accounts/delete", post(delete_accounts::<S>))
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/quota/refresh",
@@ -567,9 +971,17 @@ where
post(refresh_account_models::<S>),
)
.route(
"/api/admin/accounts/test",
"/api/admin/accounts/connection-test",
get(test_account_connection::<S>),
)
.route(
"/api/admin/accounts/oauth/start",
post(start_account_authorization::<S>),
)
.route(
"/api/admin/accounts/oauth/complete",
post(complete_account_authorization::<S>),
)
}
async fn list_accounts<S>(
@@ -593,6 +1005,27 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn export_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
@@ -616,6 +1049,208 @@ where
Ok(response)
}
async fn import_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountImportRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.import_document(command)
.await
}
AccountProvider::Xai => state.admin_services().xai().import_document(command).await,
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountImportData::from(result)),
))
}
async fn start_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<StartAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.start_authorization(command)
.await
}
AccountProvider::Xai => {
state
.admin_services()
.xai()
.start_authorization(command)
.await
}
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountAuthorizationData::from(result)),
))
}
async fn complete_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CompleteAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.complete_authorization(command)
.await
}
AccountProvider::Xai => {
state
.admin_services()
.xai()
.complete_authorization(command)
.await
}
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountMutationData::from(result)),
))
}
async fn rotate_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<RotateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.openai()
.rotate(command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountMutationData::from(result)),
))
}
async fn enable_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_account(auth, state, request, AccountMutationAction::Enable).await
}
async fn disable_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_account(auth, state, request, AccountMutationAction::Disable).await
}
#[derive(Clone, Copy)]
enum AccountMutationAction {
Enable,
Disable,
}
async fn mutate_account<S>(
auth: AdminAuth,
state: S,
request: AccountMutationRequest,
action: AccountMutationAction,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match (provider, action) {
(AccountProvider::OpenAi, AccountMutationAction::Enable) => {
state.admin_services().openai().enable(command).await
}
(AccountProvider::OpenAi, AccountMutationAction::Disable) => {
state.admin_services().openai().disable(command).await
}
(AccountProvider::Xai, AccountMutationAction::Enable) => {
state.admin_services().xai().enable(command).await
}
(AccountProvider::Xai, AccountMutationAction::Disable) => {
state.admin_services().xai().disable(command).await
}
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountMutationData::from(result)),
))
}
async fn delete_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountDeletionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => state.admin_services().openai().delete(command).await,
AccountProvider::Xai => state.admin_services().xai().delete(command).await,
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountDeletionData::from(result)),
))
}
async fn refresh_account<S>(
auth: AdminAuth,
State(state): State<S>,
@@ -796,7 +1431,6 @@ fn account_models_data(result: ProviderModels) -> AccountModelsData {
fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
let AccountDirectoryItem {
account,
provider_instance_name,
status,
usage,
quota,
@@ -810,8 +1444,6 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
id: account.id.clone(),
name: account.name,
provider: account.provider_kind.to_string(),
provider_instance_id: account.provider_instance_id.to_string(),
provider_instance_name,
resource_ref: account.id,
email: account.email,
account_id: account.upstream_account_id,
@@ -1069,6 +1701,83 @@ fn empty_account_usage() -> AccountUsageView {
}
}
fn revision(value: u64, field: &'static str) -> Result<Revision, WireValidationError> {
Revision::new(value).map_err(|_| WireValidationError::new(field))
}
fn provider_document(
value: Value,
field: &'static str,
) -> Result<ProviderDocument, WireValidationError> {
match value {
Value::Object(document) => Ok(ProviderDocument::new(OpaqueProviderData::new(document))),
_ => Err(WireValidationError::new(field)),
}
}
fn require_wire_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
require_text(value, MAX_ID_BYTES, field)?;
if value.starts_with("__")
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
{
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_text(
value: &str,
max_bytes: usize,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_positive(value: u64, field: &'static str) -> Result<(), WireValidationError> {
if value == 0 || i64::try_from(value).is_err() {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn validate_oauth_material(
access_token: &str,
refresh_token: Option<&str>,
) -> Result<(), WireValidationError> {
if access_token.len() > MAX_ACCESS_TOKEN_BYTES
|| !valid_visible_ascii(access_token)
|| !valid_compact_jwt_shape(access_token)
{
return Err(WireValidationError::new("accessToken"));
}
if refresh_token.is_some_and(|token| {
token.len() > MAX_REFRESH_TOKEN_BYTES
|| !valid_visible_ascii(token)
|| token == access_token
}) {
return Err(WireValidationError::new("refreshToken"));
}
Ok(())
}
fn valid_compact_jwt_shape(value: &str) -> bool {
let mut segments = value.split('.');
matches!(
(segments.next(), segments.next(), segments.next(), segments.next()),
(Some(header), Some(payload), Some(signature), None)
if !header.is_empty() && !payload.is_empty() && !signature.is_empty()
)
}
fn valid_visible_ascii(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
}
fn provider_document_value(document: ProviderDocument) -> Value {
Value::Object(document.into_provider_data().into_inner())
}
+2 -2
View File
@@ -207,9 +207,9 @@ where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/login", post(login::<S>))
.route("/api/admin/auth/login", post(login::<S>))
.route("/api/admin/auth/status", get(session_status::<S>))
.route("/api/admin/logout", post(logout::<S>))
.route("/api/admin/auth/logout", post(logout::<S>))
}
async fn login<S>(
@@ -1,523 +0,0 @@
//! Provider instance 管理边界。
use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::{
PageSize, Revision,
catalog::{
CatalogListQuery as CatalogQuery, CreateProviderInstance, DeleteProviderInstance,
ProviderInstance, ProviderInstanceDetail, ProviderInstanceMutation, ProviderInstancePage,
SetProviderInstanceEnabled, UpdateProviderInstance,
},
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
wire::map_admin_service_error,
};
const DEFAULT_PAGE_SIZE: u16 = 50;
const MAX_PAGE_SIZE: u16 = 200;
const MAX_CURSOR_BYTES: usize = 1024;
const MAX_TEXT_BYTES: usize = 2048;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CatalogListQuery {
pub cursor: Option<String>,
pub limit: Option<u16>,
}
impl CatalogListQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())?;
validate_limit(self.limit)
}
#[must_use]
pub fn page_size(&self) -> u16 {
self.limit.unwrap_or(DEFAULT_PAGE_SIZE)
}
fn into_command(self) -> Result<CatalogQuery, WireValidationError> {
self.validate()?;
let page_size =
PageSize::new(self.page_size()).map_err(|_| WireValidationError::new("limit"))?;
let cursor = self
.cursor
.map(ProviderInstanceId::new)
.transpose()
.map_err(|_| WireValidationError::new("catalogCursor"))?;
Ok(CatalogQuery { cursor, page_size })
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CatalogIdQuery {
pub id: String,
}
impl CatalogIdQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_id(&self.id, "id")
}
fn into_id(self) -> Result<ProviderInstanceId, WireValidationError> {
self.validate()?;
ProviderInstanceId::new(self.id)
.map_err(|_| WireValidationError::new("catalogDetailNotFound"))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogMutationData {
pub config_revision: u64,
pub id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateProviderInstanceRequest {
pub id: String,
pub expected_config_revision: u64,
pub provider_kind: String,
pub name: String,
pub base_url: String,
}
impl CreateProviderInstanceRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_provider_instance_id(&self.id, "id")?;
require_revision(self.expected_config_revision)?;
require_provider_kind(&self.provider_kind)?;
require_text(&self.name, "name")?;
require_text(&self.base_url, "baseUrl")
}
fn into_command(self) -> Result<CreateProviderInstance, WireValidationError> {
self.validate()?;
Ok(CreateProviderInstance {
expected_config_revision: revision(self.expected_config_revision)?,
id: ProviderInstanceId::new(self.id).map_err(|_| WireValidationError::new("id"))?,
provider_kind: ProviderKind::new(self.provider_kind)
.map_err(|_| WireValidationError::new("providerKind"))?,
name: self.name,
base_url: self.base_url,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateProviderInstanceRequest {
pub id: String,
pub expected_config_revision: u64,
pub name: String,
pub base_url: String,
}
impl UpdateProviderInstanceRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_provider_instance_id(&self.id, "id")?;
require_revision(self.expected_config_revision)?;
require_text(&self.name, "name")?;
require_text(&self.base_url, "baseUrl")
}
fn into_command(self) -> Result<UpdateProviderInstance, WireValidationError> {
self.validate()?;
Ok(UpdateProviderInstance {
expected_config_revision: revision(self.expected_config_revision)?,
id: ProviderInstanceId::new(self.id).map_err(|_| WireValidationError::new("id"))?,
name: self.name,
base_url: self.base_url,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CatalogRevisionRequest {
pub id: String,
pub expected_config_revision: u64,
}
impl CatalogRevisionRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_id(&self.id, "id")?;
require_revision(self.expected_config_revision)
}
fn into_parts(self) -> Result<(Revision, ProviderInstanceId), WireValidationError> {
self.validate()?;
Ok((
revision(self.expected_config_revision)?,
ProviderInstanceId::new(self.id)
.map_err(|_| WireValidationError::new("catalogMutationNotFound"))?,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderInstanceView {
pub id: String,
pub provider_kind: String,
pub name: String,
pub base_url: String,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<ProviderInstance> for ProviderInstanceView {
fn from(instance: ProviderInstance) -> Self {
Self {
id: instance.id.to_string(),
provider_kind: instance.provider_kind.to_string(),
name: instance.name,
base_url: instance.base_url,
enabled: instance.enabled,
created_at: instance.created_at,
updated_at: instance.updated_at,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderInstanceListData {
pub config_revision: u64,
pub items: Vec<ProviderInstanceView>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderInstanceDetailData {
pub config_revision: u64,
pub item: ProviderInstanceView,
}
impl From<ProviderInstancePage> for ProviderInstanceListData {
fn from(page: ProviderInstancePage) -> Self {
Self {
config_revision: page.config_revision.get(),
items: page.items.into_iter().map(Into::into).collect(),
next_cursor: page.next_cursor.map(|cursor| cursor.to_string()),
}
}
}
impl From<ProviderInstanceDetail> for ProviderInstanceDetailData {
fn from(detail: ProviderInstanceDetail) -> Self {
Self {
config_revision: detail.config_revision.get(),
item: detail.item.into(),
}
}
}
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route(
"/api/admin/provider-instances",
get(list_provider_instances::<S>),
)
.route(
"/api/admin/provider-instances/detail",
get(provider_instance::<S>),
)
.route(
"/api/admin/provider-instances/create",
post(create_provider_instance::<S>),
)
.route(
"/api/admin/provider-instances/update",
post(update_provider_instance::<S>),
)
.route(
"/api/admin/provider-instances/enable",
post(enable_provider_instance::<S>),
)
.route(
"/api/admin/provider-instances/disable",
post(disable_provider_instance::<S>),
)
.route(
"/api/admin/provider-instances/delete",
post(delete_provider_instance::<S>),
)
}
async fn list_provider_instances<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<CatalogListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.catalog()
.list(query.into_command().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProviderInstanceListData::from(result)),
))
}
async fn provider_instance<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<CatalogIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.catalog()
.get(&id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProviderInstanceDetailData::from(result)),
))
}
async fn create_provider_instance<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CreateProviderInstanceRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let id = command.id.to_string();
let result = state
.admin_services()
.catalog()
.create(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
mutation_response(StatusCode::CREATED, result, id)
}
async fn update_provider_instance<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateProviderInstanceRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let id = command.id.to_string();
let result = state
.admin_services()
.catalog()
.update(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
mutation_response(StatusCode::OK, result, id)
}
async fn enable_provider_instance<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CatalogRevisionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
set_provider_instance_enabled(auth, state, request, true).await
}
async fn disable_provider_instance<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CatalogRevisionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
set_provider_instance_enabled(auth, state, request, false).await
}
async fn set_provider_instance_enabled<S>(
auth: AdminAuth,
state: S,
request: CatalogRevisionRequest,
enabled: bool,
) -> Result<AdminResponse<AdminEnvelope<CatalogMutationData>>, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (expected_config_revision, id) = request.into_parts().map_err(map_wire_error)?;
let response_id = id.to_string();
let result = state
.admin_services()
.catalog()
.set_enabled(
&auth.context().mutation_context(),
SetProviderInstanceEnabled {
expected_config_revision,
id,
enabled,
},
)
.await
.map_err(map_service_error)?;
mutation_response(StatusCode::OK, result, response_id)
}
async fn delete_provider_instance<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CatalogRevisionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (expected_config_revision, id) = request.into_parts().map_err(map_wire_error)?;
let response_id = id.to_string();
let config_revision = state
.admin_services()
.catalog()
.delete(
&auth.context().mutation_context(),
DeleteProviderInstance {
expected_config_revision,
id,
},
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(CatalogMutationData {
config_revision: config_revision.get(),
id: response_id,
}),
))
}
fn mutation_response(
status: StatusCode,
result: ProviderInstanceMutation,
fallback_id: String,
) -> Result<AdminResponse<AdminEnvelope<CatalogMutationData>>, AdminError> {
let id = result
.instance
.map_or(fallback_id, |instance| instance.id.to_string());
Ok(AdminResponse::new(
status,
AdminEnvelope::ok(CatalogMutationData {
config_revision: result.config_revision.get(),
id,
}),
))
}
fn require_revision(value: u64) -> Result<(), WireValidationError> {
if value == 0 || i64::try_from(value).is_err() {
return Err(WireValidationError::new("expectedConfigRevision"));
}
Ok(())
}
fn revision(value: u64) -> Result<Revision, WireValidationError> {
require_revision(value)?;
Revision::new(value).map_err(|_| WireValidationError::new("expectedConfigRevision"))
}
fn require_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
require_text(value, field)?;
if value.starts_with("__")
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
{
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_provider_instance_id(
value: &str,
field: &'static str,
) -> Result<(), WireValidationError> {
ProviderInstanceId::new(value).map_or_else(|_| Err(WireValidationError::new(field)), |_| Ok(()))
}
fn require_text(value: &str, field: &'static str) -> Result<(), WireValidationError> {
if value.trim().is_empty()
|| value.len() > MAX_TEXT_BYTES
|| value.chars().any(char::is_control)
{
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_provider_kind(value: &str) -> Result<(), WireValidationError> {
if matches!(value, "openai" | "xai") {
Ok(())
} else {
Err(WireValidationError::new("providerKind"))
}
}
fn validate_cursor(cursor: Option<&str>) -> Result<(), WireValidationError> {
if cursor.is_some_and(|value| value.is_empty() || value.len() > MAX_CURSOR_BYTES) {
Err(WireValidationError::new("cursor"))
} else {
Ok(())
}
}
fn validate_limit(limit: Option<u16>) -> Result<(), WireValidationError> {
if limit.is_some_and(|value| value == 0 || value > MAX_PAGE_SIZE) {
Err(WireValidationError::new("limit"))
} else {
Ok(())
}
}
fn map_wire_error(error: WireValidationError) -> AdminError {
match error.field() {
"catalogCursor" => AdminError::bad_request("Invalid catalog cursor"),
"catalogDetailNotFound" => AdminError::not_found("Provider instance was not found"),
"catalogMutationNotFound" => AdminError::not_found("provider instance was not found"),
field => AdminError::bad_request(format!("Invalid field: {field}")),
}
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
if error.kind() == gateway_admin::model::AdminErrorKind::Invalid
&& error.to_string() == "Invalid provider instance cursor"
{
return AdminError::bad_request("Invalid catalog cursor");
}
map_admin_service_error(error, "Catalog unavailable")
}
@@ -665,9 +665,10 @@ where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/client-keys", get(list_client_keys::<S>))
.route(
"/api/admin/client-keys",
get(list_client_keys::<S>).post(create_client_key::<S>),
"/api/admin/client-keys/create",
post(create_client_key::<S>),
)
.route("/api/admin/client-keys/reveal", get(reveal_client_key::<S>))
.route(
@@ -9,14 +9,11 @@ use axum::{
pub mod accounts;
pub mod auth;
pub mod catalog;
pub mod client_keys;
pub mod observability;
pub mod openai;
pub mod settings;
pub mod system;
pub mod wire;
pub mod xai;
pub use auth::{AdminAuth, AdminSessionState};
pub use wire::{
@@ -32,13 +29,10 @@ where
Router::new()
.merge(accounts::router::<S>())
.merge(auth::router::<S>())
.merge(catalog::router::<S>())
.merge(client_keys::router::<S>())
.merge(openai::router::<S>())
.merge(observability::router::<S>())
.merge(settings::router::<S>())
.merge(system::router::<S>())
.merge(xai::router::<S>())
.layer(middleware::map_response(no_store))
}
@@ -642,7 +642,6 @@ pub struct UsageAttemptView {
pub attempt_index: u32,
pub trigger: String,
pub provider: String,
pub provider_instance_id: String,
pub model: String,
pub transport: String,
pub send_state: String,
@@ -1183,7 +1182,6 @@ pub struct OpsErrorMetadataView {
pub source: String,
pub component: String,
pub attempt_id: Option<String>,
pub provider_instance_id: Option<String>,
pub account_label: Option<String>,
}
@@ -1204,14 +1202,14 @@ where
get(usage_records_summary::<S>),
)
.route(
"/api/admin/usage/records/insights/overview",
"/api/admin/usage/insights/overview",
get(usage_insights_overview::<S>),
)
.route(
"/api/admin/usage/records/insights/diagnostics",
"/api/admin/usage/insights/diagnostics",
get(usage_insights_diagnostics::<S>),
)
.route("/api/admin/ops/errors", get(ops_errors::<S>))
.route("/api/admin/operations/errors", get(ops_errors::<S>))
}
async fn dashboard_summary<S>(
@@ -1744,9 +1742,6 @@ fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptView {
provider: attempt
.provider_kind
.unwrap_or_else(|| "unknown".to_owned()),
provider_instance_id: attempt
.provider_instance_id
.unwrap_or_else(|| "unknown".to_owned()),
model: attempt
.upstream_model_id
.unwrap_or_else(|| "unknown".to_owned()),
@@ -1843,7 +1838,6 @@ fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
source: error.source,
component: error.component,
attempt_id: None,
provider_instance_id: error.provider_instance_id,
account_label: None,
},
created_at: error.occurred_at,
@@ -1,895 +0,0 @@
//! Codex OAuth credential 管理端 wire contract。
use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use gateway_admin::model::{
AdminError as AdminServiceError, AdminErrorKind, PageSize, Revision,
accounts::{AccountAvailability, AccountRecord},
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialAvailabilityFilter,
CredentialCursor, CredentialDetails, CredentialImportResult, CredentialListQuery,
CredentialListWindow, CredentialMutation, CredentialMutationResult, CredentialPage,
ImportCredentials, ProviderDocument, ReauthorizationTarget, RotateCredential,
StartAuthorization,
},
};
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId},
routing::ProviderInstanceId,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::admin::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
};
pub const DEFAULT_PAGE_SIZE: u16 = 50;
pub const MAX_PAGE_SIZE: u16 = 200;
const MAX_ID_BYTES: usize = 256;
const MAX_NAME_BYTES: usize = 512;
const MAX_CURSOR_BYTES: usize = 1_024;
const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1_024 * 1_024;
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1_024;
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1_024;
const MAX_CALLBACK_URL_BYTES: usize = 16 * 1_024;
/// Codex credential 列表查询。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ListCredentialsQuery {
pub provider_instance_id: Option<String>,
pub availability: Option<String>,
pub enabled: Option<bool>,
pub cursor: Option<String>,
pub limit: Option<u16>,
}
impl ListCredentialsQuery {
/// 校验筛选与游标边界。
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(provider_instance_id) = self.provider_instance_id.as_deref() {
require_id(provider_instance_id, "providerInstanceId")?;
}
if self.availability.as_deref().is_some_and(|value| {
!matches!(
value,
"unknown" | "ready" | "cooldown" | "exhausted" | "invalid"
)
}) {
return Err(WireValidationError::new("availability"));
}
if self
.cursor
.as_deref()
.is_some_and(|value| value.is_empty() || value.len() > MAX_CURSOR_BYTES)
{
return Err(WireValidationError::new("cursor"));
}
if self
.limit
.is_some_and(|limit| limit == 0 || limit > MAX_PAGE_SIZE)
{
return Err(WireValidationError::new("limit"));
}
Ok(())
}
fn into_command(self) -> Result<CredentialListQuery, WireValidationError> {
self.validate()?;
Ok(CredentialListQuery {
provider_instance_id: self
.provider_instance_id
.map(ProviderInstanceId::new)
.transpose()
.map_err(|_| WireValidationError::new("providerInstanceId"))?,
availability: self
.availability
.as_deref()
.map(parse_availability)
.transpose()?,
enabled: self.enabled,
window: CredentialListWindow::Page {
cursor: self.cursor.as_deref().map(decode_cursor).transpose()?,
page_size: PageSize::new(self.limit.unwrap_or(DEFAULT_PAGE_SIZE))
.map_err(|_| WireValidationError::new("limit"))?,
},
})
}
}
/// Codex credential 详情查询。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialDetailsQuery {
pub credential_id: String,
}
impl CredentialDetailsQuery {
/// 校验 credential ID。
pub fn validate(&self) -> Result<(), WireValidationError> {
require_id(&self.credential_id, "credentialId")
}
fn into_account_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.credential_id)
.map_err(|_| WireValidationError::new("credentialId"))
}
}
/// OpenAI Provider-owned 账号导入文档。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ImportCredentialsDocumentRequest {
pub expected_config_revision: u64,
pub provider_instance_id: String,
pub document: Value,
}
impl ImportCredentialsDocumentRequest {
/// Wire 只约束文档大小;结构识别与 token 验证完全归 Provider。
pub fn validate(&self) -> Result<(), WireValidationError> {
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
require_id(&self.provider_instance_id, "providerInstanceId")?;
if !self.document.is_object()
|| serde_json::to_vec(&self.document)
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DOCUMENT_BYTES)
{
return Err(WireValidationError::new("document"));
}
Ok(())
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<ImportCredentials, WireValidationError> {
self.validate()?;
Ok(ImportCredentials {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
provider_instance_id: ProviderInstanceId::new(self.provider_instance_id)
.map_err(|_| WireValidationError::new("providerInstanceId"))?,
document: provider_document(self.document, "document")?,
})
}
}
/// 启动 Codex Authorization Code + PKCE;可创建新账号或绑定既有账号重新授权。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartOAuthAuthorizationRequest {
pub expected_config_revision: u64,
pub provider_instance_id: String,
pub name: String,
pub credential_id: Option<String>,
pub expected_credential_revision: Option<u64>,
}
impl StartOAuthAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
require_id(&self.provider_instance_id, "providerInstanceId")?;
require_text(&self.name, MAX_NAME_BYTES, "name")?;
match (
self.credential_id.as_deref(),
self.expected_credential_revision,
) {
(None, None) => {}
(Some(credential_id), Some(revision)) => {
require_id(credential_id, "credentialId")?;
require_positive(revision, "expectedCredentialRevision")?;
}
_ => return Err(WireValidationError::new("reauthorization")),
}
Ok(())
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<StartAuthorization, WireValidationError> {
self.validate()?;
let reauthorization = match (self.credential_id, self.expected_credential_revision) {
(None, None) => None,
(Some(account_id), Some(credential_revision)) => Some(ReauthorizationTarget {
account_id: ProviderAccountId::new(account_id)
.map_err(|_| WireValidationError::new("credentialId"))?,
credential_revision: revision(credential_revision, "expectedCredentialRevision")?,
}),
_ => return Err(WireValidationError::new("reauthorization")),
};
Ok(StartAuthorization {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
provider_instance_id: ProviderInstanceId::new(self.provider_instance_id)
.map_err(|_| WireValidationError::new("providerInstanceId"))?,
name: self.name,
reauthorization,
})
}
}
/// 完成 Codex Authorization Code flow;flow ID 与 callback 都放在 POST body。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteOAuthAuthorizationRequest {
pub flow_id: String,
pub callback_url: String,
}
impl CompleteOAuthAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if !URL_SAFE_NO_PAD
.decode(&self.flow_id)
.is_ok_and(|decoded| decoded.len() == 32)
{
return Err(WireValidationError::new("flowId"));
}
if self.callback_url.is_empty()
|| self.callback_url.len() > MAX_CALLBACK_URL_BYTES
|| self.callback_url.chars().any(char::is_control)
{
return Err(WireValidationError::new("callbackUrl"));
}
Ok(())
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<CompleteAuthorization, WireValidationError> {
self.validate()?;
Ok(CompleteAuthorization {
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
})
}
}
/// Codex OAuth token 轮换请求。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RotateCredentialRequest {
pub credential_id: String,
pub expected_config_revision: u64,
pub expected_credential_revision: u64,
pub access_token: String,
pub refresh_token: Option<String>,
}
impl RotateCredentialRequest {
/// 校验 ID、revision 与 OAuth material 的 wire 形状。
pub fn validate(&self) -> Result<(), WireValidationError> {
require_id(&self.credential_id, "credentialId")?;
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
require_positive(
self.expected_credential_revision,
"expectedCredentialRevision",
)?;
validate_oauth_material(&self.access_token, self.refresh_token.as_deref())
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<RotateCredential, WireValidationError> {
self.validate()?;
let mut material = Map::new();
material.insert("access_token".to_owned(), Value::String(self.access_token));
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
Ok(RotateCredential {
mutation: CredentialMutation {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_id: ProviderAccountId::new(self.credential_id)
.map_err(|_| WireValidationError::new("credentialId"))?,
},
expected_credential_revision: revision(
self.expected_credential_revision,
"expectedCredentialRevision",
)?,
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
})
}
}
/// Codex credential 生命周期 mutation 请求。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialMutationRequest {
pub credential_id: String,
pub expected_config_revision: u64,
}
impl CredentialMutationRequest {
/// 校验 ID 与配置 revision。
pub fn validate(&self) -> Result<(), WireValidationError> {
require_id(&self.credential_id, "credentialId")?;
require_positive(self.expected_config_revision, "expectedConfigRevision")
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<CredentialMutation, WireValidationError> {
self.validate()?;
Ok(CredentialMutation {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_id: ProviderAccountId::new(self.credential_id)
.map_err(|_| WireValidationError::new("credentialId"))?,
})
}
}
/// 列表游标的 JSON 形状;HTTP 组合层负责 base64url 编解码。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialCursorWire {
pub created_at: DateTime<Utc>,
pub credential_id: String,
}
/// Admin API 可安全返回的 Codex credential 视图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCredentialView {
pub id: String,
pub provider_instance_id: String,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_account_id: Option<String>,
pub enabled: bool,
pub credential_revision: i64,
pub has_refresh_token: bool,
pub availability: String,
pub availability_reason: Option<String>,
pub plan_type: Option<String>,
pub access_token_expires_at: DateTime<Utc>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub cooldown_until: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Codex credential 列表响应数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCredentialListData {
pub config_revision: u64,
pub items: Vec<CodexCredentialView>,
pub next_cursor: Option<String>,
}
/// Codex credential 详情响应数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCredentialDetailsData {
pub config_revision: u64,
pub credential: CodexCredentialView,
}
/// 已提交的结构配置 mutation 响应数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCredentialMutationData {
pub config_revision: u64,
pub credential_id: String,
}
/// Provider 正式文档一次原子导入的结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCredentialsDocumentImportData {
pub config_revision: u64,
pub credential_ids: Vec<String>,
}
/// Authorization Code + PKCE 启动响应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexOAuthAuthorizationStartedData {
pub flow_id: String,
pub authorization_url: String,
pub expires_at: DateTime<Utc>,
}
/// 已提交的 token 轮换响应数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCredentialRotationData {
pub credential_id: String,
pub credential_revision: i64,
}
impl From<AccountRecord> for CodexCredentialView {
fn from(account: AccountRecord) -> Self {
Self {
id: account.id,
provider_instance_id: account.provider_instance_id.to_string(),
name: account.name,
email: account.email,
upstream_user_id: account.upstream_user_id,
upstream_account_id: account.upstream_account_id,
enabled: account.enabled,
credential_revision: i64::try_from(account.credential_revision.get())
.unwrap_or(i64::MAX),
has_refresh_token: account.has_refresh_token,
availability: codex_availability(account.availability).to_owned(),
availability_reason: account.availability_reason,
plan_type: account.plan_type,
access_token_expires_at: account.access_token_expires_at,
next_refresh_at: account.next_refresh_at,
cooldown_until: account.cooldown_until,
created_at: account.created_at,
updated_at: account.updated_at,
}
}
}
impl From<CredentialDetails> for CodexCredentialDetailsData {
fn from(details: CredentialDetails) -> Self {
Self {
config_revision: details.config_revision.get(),
credential: details.credential.into(),
}
}
}
impl From<CredentialImportResult> for CodexCredentialsDocumentImportData {
fn from(result: CredentialImportResult) -> Self {
Self {
config_revision: result.config_revision.get(),
credential_ids: result
.credential_ids
.into_iter()
.map(|id| id.to_string())
.collect(),
}
}
}
impl From<AuthorizationStarted> for CodexOAuthAuthorizationStartedData {
fn from(started: AuthorizationStarted) -> Self {
Self {
flow_id: started.flow_id,
authorization_url: started.authorization_url,
expires_at: started.expires_at,
}
}
}
impl From<CredentialMutationResult> for CodexCredentialMutationData {
fn from(result: CredentialMutationResult) -> Self {
Self {
config_revision: result.config_revision.get(),
credential_id: result.account_id.to_string(),
}
}
}
fn validate_oauth_material(
access_token: &str,
refresh_token: Option<&str>,
) -> Result<(), WireValidationError> {
if access_token.len() > MAX_ACCESS_TOKEN_BYTES
|| !valid_visible_ascii(access_token)
|| !valid_compact_jwt_shape(access_token)
{
return Err(WireValidationError::new("accessToken"));
}
if refresh_token.is_some_and(|token| {
token.len() > MAX_REFRESH_TOKEN_BYTES
|| !valid_visible_ascii(token)
|| token == access_token
}) {
return Err(WireValidationError::new("refreshToken"));
}
Ok(())
}
fn valid_compact_jwt_shape(value: &str) -> bool {
let mut segments = value.split('.');
matches!(
(segments.next(), segments.next(), segments.next(), segments.next()),
(Some(header), Some(payload), Some(signature), None)
if !header.is_empty() && !payload.is_empty() && !signature.is_empty()
)
}
fn valid_visible_ascii(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
}
fn require_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
require_text(value, MAX_ID_BYTES, field)?;
if value.starts_with("__") {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_text(
value: &str,
max_bytes: usize,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_positive(value: u64, field: &'static str) -> Result<(), WireValidationError> {
if value == 0 || i64::try_from(value).is_err() {
return Err(WireValidationError::new(field));
}
Ok(())
}
/// 构造固定 GET/POST Codex credential 管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/openai/credentials", get(list_credentials::<S>))
.route("/api/admin/openai/credential", get(credential_details::<S>))
.route(
"/api/admin/openai/credentials/import-document",
post(import_credentials_document::<S>),
)
.route(
"/api/admin/openai/oauth/authorization/start",
post(start_oauth_authorization::<S>),
)
.route(
"/api/admin/openai/oauth/authorization/complete",
post(complete_oauth_authorization::<S>),
)
.route(
"/api/admin/openai/credentials/rotate",
post(rotate_credential::<S>),
)
.route(
"/api/admin/openai/credentials/enable",
post(enable_credential::<S>),
)
.route(
"/api/admin/openai/credentials/disable",
post(disable_credential::<S>),
)
.route(
"/api/admin/openai/credentials/delete",
post(delete_credential::<S>),
)
}
async fn list_credentials<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListCredentialsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = query.into_command().map_err(map_wire_error)?;
let page = state
.admin_services()
.openai()
.list(command)
.await
.map_err(map_service_error)?;
ok(StatusCode::OK, credential_list_data(page)?)
}
async fn credential_details<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<CredentialDetailsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_account_id().map_err(map_wire_error)?;
let details = state
.admin_services()
.openai()
.details(&account_id)
.await
.map_err(map_service_error)?;
ok(StatusCode::OK, CodexCredentialDetailsData::from(details))
}
async fn import_credentials_document<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<ImportCredentialsDocumentRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.openai()
.import_document(command)
.await
.map_err(map_service_error)?;
ok(
StatusCode::CREATED,
CodexCredentialsDocumentImportData::from(result),
)
}
async fn start_oauth_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<StartOAuthAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.openai()
.start_authorization(command)
.await
.map_err(map_service_error)?;
ok(
StatusCode::CREATED,
CodexOAuthAuthorizationStartedData::from(result),
)
}
async fn complete_oauth_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CompleteOAuthAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.openai()
.complete_authorization(command)
.await
.map_err(map_service_error)?;
ok(
StatusCode::CREATED,
CodexCredentialMutationData::from(result),
)
}
async fn rotate_credential<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<RotateCredentialRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.openai()
.rotate(command)
.await
.map_err(map_service_error)?;
ok(StatusCode::OK, credential_rotation_data(result)?)
}
async fn enable_credential<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CredentialMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_credential(auth, state, request, CredentialAction::Enable).await
}
async fn disable_credential<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CredentialMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_credential(auth, state, request, CredentialAction::Disable).await
}
async fn delete_credential<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CredentialMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_credential(auth, state, request, CredentialAction::Delete).await
}
#[derive(Clone, Copy)]
enum CredentialAction {
Enable,
Disable,
Delete,
}
async fn mutate_credential<S>(
auth: AdminAuth,
state: S,
request: CredentialMutationRequest,
action: CredentialAction,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let service = state.admin_services().openai();
let result = match action {
CredentialAction::Enable => service.enable(command).await,
CredentialAction::Disable => service.disable(command).await,
CredentialAction::Delete => service.delete(command).await,
}
.map_err(map_service_error)?;
ok(StatusCode::OK, CodexCredentialMutationData::from(result))
}
fn ok<T: Serialize>(
status: StatusCode,
data: T,
) -> Result<AdminResponse<AdminEnvelope<T>>, AdminError> {
Ok(AdminResponse::new(status, AdminEnvelope::ok(data)))
}
fn map_wire_error(error: WireValidationError) -> AdminError {
AdminError::bad_request(format!("Invalid Codex credential field: {}", error.field()))
}
fn map_service_error(error: AdminServiceError) -> AdminError {
match error.kind() {
AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()),
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
AdminErrorKind::NotFound => AdminError::not_found(error.to_string()),
AdminErrorKind::Conflict => AdminError::conflict(error.to_string()),
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
AdminErrorKind::BadGateway => AdminError::bad_gateway(error.to_string()),
AdminErrorKind::Unavailable => AdminError::service_unavailable(error.to_string()),
AdminErrorKind::Internal => AdminError::internal(error.to_string()),
}
}
fn revision(value: u64, field: &'static str) -> Result<Revision, WireValidationError> {
Revision::new(value).map_err(|_| WireValidationError::new(field))
}
fn provider_document(
value: Value,
field: &'static str,
) -> Result<ProviderDocument, WireValidationError> {
match value {
Value::Object(document) => Ok(ProviderDocument::new(OpaqueProviderData::new(document))),
_ => Err(WireValidationError::new(field)),
}
}
fn parse_availability(value: &str) -> Result<CredentialAvailabilityFilter, WireValidationError> {
match value {
"unknown" => Ok(CredentialAvailabilityFilter::Exact(
AccountAvailability::Unknown,
)),
"ready" => Ok(CredentialAvailabilityFilter::Exact(
AccountAvailability::Ready,
)),
"cooldown" => Ok(CredentialAvailabilityFilter::Exact(
AccountAvailability::Cooldown,
)),
"exhausted" => Ok(CredentialAvailabilityFilter::Exact(
AccountAvailability::QuotaExhausted,
)),
"invalid" => Ok(CredentialAvailabilityFilter::AnyOf(vec![
AccountAvailability::Expired,
AccountAvailability::Banned,
AccountAvailability::Invalid,
])),
_ => Err(WireValidationError::new("availability")),
}
}
fn decode_cursor(value: &str) -> Result<CredentialCursor, WireValidationError> {
let encoded = URL_SAFE_NO_PAD
.decode(value)
.map_err(|_| WireValidationError::new("cursor"))?;
let wire: CredentialCursorWire =
serde_json::from_slice(&encoded).map_err(|_| WireValidationError::new("cursor"))?;
require_id(&wire.credential_id, "cursor")?;
Ok(CredentialCursor {
created_at: wire.created_at,
account_id: ProviderAccountId::new(wire.credential_id)
.map_err(|_| WireValidationError::new("cursor"))?,
})
}
fn encode_cursor(cursor: &CredentialCursor) -> Result<String, AdminError> {
let encoded = serde_json::to_vec(&CredentialCursorWire {
created_at: cursor.created_at,
credential_id: cursor.account_id.to_string(),
})
.map_err(|_| AdminError::internal("Failed to encode credential cursor"))?;
Ok(URL_SAFE_NO_PAD.encode(encoded))
}
fn credential_list_data(page: CredentialPage) -> Result<CodexCredentialListData, AdminError> {
Ok(CodexCredentialListData {
config_revision: page.config_revision.get(),
items: page.items.into_iter().map(Into::into).collect(),
next_cursor: page.next_cursor.as_ref().map(encode_cursor).transpose()?,
})
}
fn credential_rotation_data(
result: CredentialMutationResult,
) -> Result<CodexCredentialRotationData, AdminError> {
let credential_revision = result
.credential_revision
.ok_or_else(|| AdminError::internal("Credential revision is missing"))?;
Ok(CodexCredentialRotationData {
credential_id: result.account_id.to_string(),
credential_revision: i64::try_from(credential_revision.get()).unwrap_or(i64::MAX),
})
}
const fn codex_availability(value: AccountAvailability) -> &'static str {
match value {
AccountAvailability::Unknown => "unknown",
AccountAvailability::Ready => "ready",
AccountAvailability::Cooldown => "cooldown",
AccountAvailability::QuotaExhausted => "exhausted",
AccountAvailability::Expired
| AccountAvailability::Banned
| AccountAvailability::Invalid => "invalid",
}
}
@@ -174,13 +174,15 @@ where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route(
"/api/admin/settings",
get(settings::<S>).post(update_settings::<S>),
)
.route("/api/admin/settings", get(settings::<S>))
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/admin-api-key",
get(admin_api_key_status::<S>).post(delete_admin_api_key::<S>),
get(admin_api_key_status::<S>),
)
.route(
"/api/admin/settings/admin-api-key/delete",
post(delete_admin_api_key::<S>),
)
.route(
"/api/admin/settings/admin-api-key/regenerate",
@@ -216,13 +216,13 @@ where
{
Router::new()
.route("/api/admin/system/version", get(version::<S>))
.route("/api/admin/system/update-detail", get(update_detail::<S>))
.route("/api/admin/system/update/detail", get(update_detail::<S>))
.route(
"/api/admin/system/update-events",
"/api/admin/system/update/events",
get(update_event_stream::<S>),
)
.route("/api/admin/system/update", post(perform_update::<S>))
.route("/api/admin/system/update-status", get(update_status::<S>))
.route("/api/admin/system/update/status", get(update_status::<S>))
.route("/api/admin/system/rollback", post(rollback::<S>))
.route("/api/admin/system/restart", post(restart::<S>))
}
-597
View File
@@ -1,597 +0,0 @@
//! xAI 管理端 wire contract。
use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::{
AdminError as AdminServiceError, AdminErrorKind, Revision,
accounts::{AccountAvailability, AccountRecord},
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialImportResult, CredentialListQuery,
CredentialListWindow, CredentialMutation, CredentialMutationResult, CredentialPage,
ImportCredentials, ProviderDocument, StartAuthorization,
},
};
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId},
routing::ProviderInstanceId,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::admin::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
};
const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
/// xAI Provider-owned 正式导入文档;API 不解释文档内部字段。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct XaiCredentialImportDocumentRequest {
pub expected_config_revision: u64,
pub provider_instance_id: String,
pub document: Value,
}
impl XaiCredentialImportDocumentRequest {
/// 只校验公共 revision、instance 和请求体边界;Provider 负责全部格式语义。
pub fn validate(&self) -> Result<(), WireValidationError> {
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
require_id(&self.provider_instance_id, "providerInstanceId")?;
if !self.document.is_object()
|| serde_json::to_vec(&self.document)
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DOCUMENT_BYTES)
{
return Err(WireValidationError::new("document"));
}
Ok(())
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<ImportCredentials, WireValidationError> {
self.validate()?;
Ok(ImportCredentials {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
provider_instance_id: ProviderInstanceId::new(self.provider_instance_id)
.map_err(|_| WireValidationError::new("providerInstanceId"))?,
document: provider_document(self.document, "document")?,
})
}
}
/// 批量导入成功响应;不包含任何 token、subject、邮箱或 client ID。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct XaiCredentialImportData {
pub config_revision: u64,
pub imported_count: usize,
pub credential_ids: Vec<String>,
}
impl XaiCredentialImportData {
#[must_use]
pub fn new(config_revision: u64, credential_ids: Vec<String>) -> Self {
Self {
config_revision,
imported_count: credential_ids.len(),
credential_ids,
}
}
}
const MAX_ID_BYTES: usize = 256;
const MAX_NAME_BYTES: usize = 512;
const MAX_CALLBACK_FIELD_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ListCredentialsQuery {
pub provider_instance_id: Option<String>,
}
impl ListCredentialsQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(id) = self.provider_instance_id.as_deref() {
require_id(id, "providerInstanceId")?;
}
Ok(())
}
fn into_command(self) -> Result<CredentialListQuery, WireValidationError> {
self.validate()?;
Ok(CredentialListQuery {
provider_instance_id: self
.provider_instance_id
.map(ProviderInstanceId::new)
.transpose()
.map_err(|_| WireValidationError::new("providerInstanceId"))?,
availability: None,
enabled: None,
window: CredentialListWindow::All,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NewCredentialRequest {
pub expected_config_revision: u64,
pub provider_instance_id: String,
pub name: String,
}
impl NewCredentialRequest {
fn validate(&self) -> Result<(), WireValidationError> {
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
require_id(&self.provider_instance_id, "providerInstanceId")?;
require_text(&self.name, MAX_NAME_BYTES, "name")?;
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartAuthorizationRequest {
#[serde(flatten)]
pub credential: NewCredentialRequest,
}
impl StartAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
self.credential.validate()
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<StartAuthorization, WireValidationError> {
self.validate()?;
Ok(StartAuthorization {
context,
expected_config_revision: revision(
self.credential.expected_config_revision,
"expectedConfigRevision",
)?,
provider_instance_id: ProviderInstanceId::new(self.credential.provider_instance_id)
.map_err(|_| WireValidationError::new("providerInstanceId"))?,
name: self.credential.name,
reauthorization: None,
})
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteAuthorizationRequest {
pub flow_id: String,
pub callback_url: String,
}
impl CompleteAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_id(&self.flow_id, "flowId")?;
require_text(&self.callback_url, MAX_CALLBACK_FIELD_BYTES, "callbackUrl")
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<CompleteAuthorization, WireValidationError> {
self.validate()?;
Ok(CompleteAuthorization {
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialMutationRequest {
pub credential_id: String,
pub expected_config_revision: u64,
}
impl CredentialMutationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_id(&self.credential_id, "credentialId")?;
require_positive(self.expected_config_revision, "expectedConfigRevision")
}
fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<CredentialMutation, WireValidationError> {
self.validate()?;
Ok(CredentialMutation {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_id: ProviderAccountId::new(self.credential_id)
.map_err(|_| WireValidationError::new("credentialId"))?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorizationStartData {
pub flow_id: String,
pub authorization_url: String,
pub expires_at: DateTime<Utc>,
}
/// xAI OAuth credential 的安全管理视图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct XaiCredentialViewData {
pub id: String,
pub provider_instance_id: String,
pub name: String,
pub email: Option<String>,
pub upstream_user_id: String,
pub upstream_account_id: Option<String>,
pub plan_type: Option<String>,
pub enabled: bool,
pub credential_revision: i64,
pub has_refresh_token: bool,
pub availability: String,
pub availability_reason: Option<String>,
pub access_token_expires_at: DateTime<Utc>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub cooldown_until: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// xAI OAuth credential 列表响应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct XaiCredentialListData {
pub config_revision: u64,
pub items: Vec<XaiCredentialViewData>,
}
/// Credential 生命周期 mutation 响应。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct XaiCredentialMutationData {
pub config_revision: u64,
pub credential_id: String,
}
impl From<AccountRecord> for XaiCredentialViewData {
fn from(account: AccountRecord) -> Self {
Self {
id: account.id,
provider_instance_id: account.provider_instance_id.to_string(),
name: account.name,
email: account.email,
upstream_user_id: account.upstream_user_id,
upstream_account_id: account.upstream_account_id,
plan_type: account.plan_type,
enabled: account.enabled,
credential_revision: i64::try_from(account.credential_revision.get())
.unwrap_or(i64::MAX),
has_refresh_token: account.has_refresh_token,
availability: availability_name(account.availability).to_owned(),
availability_reason: account.availability_reason,
access_token_expires_at: account.access_token_expires_at,
next_refresh_at: account.next_refresh_at,
cooldown_until: account.cooldown_until,
created_at: account.created_at,
updated_at: account.updated_at,
}
}
}
impl From<CredentialPage> for XaiCredentialListData {
fn from(page: CredentialPage) -> Self {
Self {
config_revision: page.config_revision.get(),
items: page.items.into_iter().map(Into::into).collect(),
}
}
}
impl From<CredentialImportResult> for XaiCredentialImportData {
fn from(result: CredentialImportResult) -> Self {
Self::new(
result.config_revision.get(),
result
.credential_ids
.into_iter()
.map(|id| id.to_string())
.collect(),
)
}
}
impl From<AuthorizationStarted> for AuthorizationStartData {
fn from(started: AuthorizationStarted) -> Self {
Self {
flow_id: started.flow_id,
authorization_url: started.authorization_url,
expires_at: started.expires_at,
}
}
}
impl From<CredentialMutationResult> for XaiCredentialMutationData {
fn from(result: CredentialMutationResult) -> Self {
Self {
config_revision: result.config_revision.get(),
credential_id: result.account_id.to_string(),
}
}
}
/// 构造固定 GET/POST xAI OAuth 管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/xai/credentials", get(list_credentials::<S>))
.route(
"/api/admin/xai/credentials/import-document",
post(import_credentials_document::<S>),
)
.route(
"/api/admin/xai/oauth/authorization/start",
post(start_authorization_code_flow::<S>),
)
.route(
"/api/admin/xai/oauth/authorization/complete",
post(complete_authorization_code_flow::<S>),
)
.route(
"/api/admin/xai/credentials/disable",
post(disable_credential::<S>),
)
.route(
"/api/admin/xai/credentials/enable",
post(enable_credential::<S>),
)
.route(
"/api/admin/xai/credentials/delete",
post(delete_credential::<S>),
)
}
async fn list_credentials<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListCredentialsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = query.into_command().map_err(map_wire_error)?;
let page = state
.admin_services()
.xai()
.list(command)
.await
.map_err(map_service_error)?;
ok(StatusCode::OK, XaiCredentialListData::from(page))
}
async fn import_credentials_document<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<XaiCredentialImportDocumentRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.xai()
.import_document(command)
.await
.map_err(map_service_error)?;
ok(StatusCode::CREATED, XaiCredentialImportData::from(result))
}
async fn start_authorization_code_flow<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<StartAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let started = state
.admin_services()
.xai()
.start_authorization(command)
.await
.map_err(map_service_error)?;
require_id(&started.flow_id, "flowId").map_err(map_wire_error)?;
ok(StatusCode::CREATED, AuthorizationStartData::from(started))
}
async fn complete_authorization_code_flow<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CompleteAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.xai()
.complete_authorization(command)
.await
.map_err(map_service_error)?;
ok(StatusCode::CREATED, XaiCredentialMutationData::from(result))
}
async fn disable_credential<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CredentialMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_credential(auth, state, request, CredentialAction::Disable).await
}
async fn enable_credential<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CredentialMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_credential(auth, state, request, CredentialAction::Enable).await
}
async fn delete_credential<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CredentialMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
mutate_credential(auth, state, request, CredentialAction::Delete).await
}
#[derive(Clone, Copy)]
enum CredentialAction {
Disable,
Enable,
Delete,
}
async fn mutate_credential<S>(
auth: AdminAuth,
state: S,
request: CredentialMutationRequest,
action: CredentialAction,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let service = state.admin_services().xai();
let result = match action {
CredentialAction::Disable => service.disable(command).await,
CredentialAction::Enable => service.enable(command).await,
CredentialAction::Delete => service.delete(command).await,
}
.map_err(map_service_error)?;
ok(StatusCode::OK, XaiCredentialMutationData::from(result))
}
fn ok<T: Serialize>(
status: StatusCode,
data: T,
) -> Result<AdminResponse<AdminEnvelope<T>>, AdminError> {
Ok(AdminResponse::new(status, AdminEnvelope::ok(data)))
}
fn map_wire_error(error: WireValidationError) -> AdminError {
AdminError::bad_request(format!("Invalid xAI OAuth field: {}", error.field()))
}
fn map_service_error(error: AdminServiceError) -> AdminError {
match error.kind() {
AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()),
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
AdminErrorKind::NotFound => AdminError::not_found(error.to_string()),
AdminErrorKind::Conflict => AdminError::conflict(error.to_string()),
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
AdminErrorKind::BadGateway | AdminErrorKind::Unavailable => {
AdminError::bad_gateway(error.to_string())
}
AdminErrorKind::Internal => AdminError::internal(error.to_string()),
}
}
fn revision(value: u64, field: &'static str) -> Result<Revision, WireValidationError> {
Revision::new(value).map_err(|_| WireValidationError::new(field))
}
fn provider_document(
value: Value,
field: &'static str,
) -> Result<ProviderDocument, WireValidationError> {
match value {
Value::Object(document) => Ok(ProviderDocument::new(OpaqueProviderData::new(document))),
_ => Err(WireValidationError::new(field)),
}
}
const fn availability_name(value: AccountAvailability) -> &'static str {
match value {
AccountAvailability::Unknown => "unknown",
AccountAvailability::Ready => "ready",
AccountAvailability::Cooldown => "cooldown",
AccountAvailability::QuotaExhausted => "quota_exhausted",
AccountAvailability::Expired => "expired",
AccountAvailability::Banned => "banned",
AccountAvailability::Invalid => "invalid",
}
}
fn require_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
require_text(value, MAX_ID_BYTES, field)?;
if value.starts_with("__")
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
{
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_text(
value: &str,
max_bytes: usize,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_positive(value: u64, field: &'static str) -> Result<(), WireValidationError> {
if value == 0 || i64::try_from(value).is_err() {
return Err(WireValidationError::new(field));
}
Ok(())
}
@@ -77,7 +77,7 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
{
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream Provider instance or account is currently available",
"no upstream provider is currently available for this request",
)
}
EngineError::Provider(error) => GatewayError::from_provider(error),
@@ -90,7 +90,7 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
EngineError::ProviderNotRegistered { .. } | EngineError::EmptyRoutingPlan => {
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream Provider instance or account is currently available",
"no upstream provider is currently available for this request",
)
}
EngineError::Store(_)
@@ -102,19 +102,23 @@ mod response {
}
mod actions {
use gateway_admin::model::accounts::{
AccountConnectionTestEvent as DomainConnectionTestEvent, AccountStatus,
use gateway_admin::model::{
Revision,
accounts::{AccountConnectionTestEvent as DomainConnectionTestEvent, AccountStatus},
provider_credentials::{CredentialDeletionResult, CredentialImportResult},
};
use gateway_api::admin::accounts::{
AccountActionRequest, AccountConnectionTestEvent, AccountExportData, AccountExportQuery,
AccountIdQuery, AccountRefreshRequest, AccountTestQuery,
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
AccountImportData, AccountImportRequest, AccountRefreshRequest, AccountTestQuery,
};
use gateway_core::engine::credential::ProviderAccountId;
use serde_json::json;
#[test]
fn export_should_require_explicit_unique_ids_and_confirmation() {
let valid: AccountExportQuery = serde_json::from_value(json!({
"ids": "acct_1,acct_2",
"accountIds": "acct_1,acct_2",
"confirm": "export_sensitive_accounts"
}))
.expect("decode export query");
@@ -129,9 +133,9 @@ mod actions {
);
for query in [
json!({ "ids": "", "confirm": "export_sensitive_accounts" }),
json!({ "ids": "acct_1,acct_1", "confirm": "export_sensitive_accounts" }),
json!({ "ids": "acct_1", "confirm": "yes" }),
json!({ "accountIds": "", "confirm": "export_sensitive_accounts" }),
json!({ "accountIds": "acct_1,acct_1", "confirm": "export_sensitive_accounts" }),
json!({ "accountIds": "acct_1", "confirm": "yes" }),
] {
assert!(
serde_json::from_value::<AccountExportQuery>(query)
@@ -145,13 +149,13 @@ mod actions {
#[test]
fn account_actions_should_require_frozen_account_ids_and_revision() {
let id: AccountIdQuery =
serde_json::from_value(json!({ "id": "acct_1" })).expect("decode ID query");
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
assert!(id.validate().is_ok());
let action: AccountActionRequest =
serde_json::from_value(json!({ "id": "legacy-id" })).expect("decode action");
assert_eq!(action.validate().unwrap_err().field(), "id");
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!({
"id": "acct_1",
"accountId": "acct_1",
"expectedConfigRevision": 0
}))
.expect("decode refresh");
@@ -161,10 +165,91 @@ mod actions {
);
}
#[test]
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"}}]
}
}))
.expect("decode account import");
assert!(valid.validate().is_ok());
let invalid: AccountImportRequest = serde_json::from_value(json!({
"provider": "xai",
"expectedConfigRevision": 7,
"data": []
}))
.expect("decode invalid account import");
assert_eq!(invalid.validate().unwrap_err().field(), "data");
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"expectedConfigRevision": 7,
"document": {}
}))
.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
}))
.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
}))
.expect("decode duplicate account deletion");
assert_eq!(duplicate.validate().unwrap_err().field(), "accountIds");
let response = AccountDeletionData::from(CredentialDeletionResult {
config_revision: Revision::new(9).expect("revision"),
account_ids: vec![
ProviderAccountId::new("acct_1").expect("account ID"),
ProviderAccountId::new("acct_2").expect("account ID"),
],
});
assert_eq!(
serde_json::to_value(response).expect("serialize account deletion"),
json!({
"configRevision": 9,
"deletedCount": 2,
"accountIds": ["acct_1", "acct_2"]
})
);
}
#[test]
fn account_import_response_should_emit_account_ids() {
let response = AccountImportData::from(CredentialImportResult {
config_revision: Revision::new(8).expect("revision"),
credential_ids: vec![ProviderAccountId::new("acct_imported").expect("account ID")],
});
assert_eq!(
serde_json::to_value(response).expect("serialize account import"),
json!({
"configRevision": 8,
"importedCount": 1,
"accountIds": ["acct_imported"]
})
);
}
#[test]
fn connection_test_should_require_model_in_query() {
let query: AccountTestQuery = serde_json::from_value(json!({
"id": "acct_1",
"accountId": "acct_1",
"modelId": " "
}))
.expect("decode connection test query");
@@ -1,128 +0,0 @@
use chrono::Utc;
use gateway_api::admin::catalog::{
CatalogListQuery, CreateProviderInstanceRequest, ProviderInstanceView,
};
use serde_json::json;
fn provider_request(provider_kind: &str) -> CreateProviderInstanceRequest {
serde_json::from_value(json!({
"id": format!("inst_{provider_kind}"),
"expectedConfigRevision": 1,
"providerKind": provider_kind,
"name": provider_kind,
"baseUrl": "https://example.invalid"
}))
.expect("decode provider instance")
}
#[test]
fn provider_instance_should_accept_openai_platform() {
assert!(provider_request("openai").validate().is_ok());
}
#[test]
fn provider_instance_should_accept_xai_platform() {
assert!(provider_request("xai").validate().is_ok());
}
#[test]
fn provider_instance_should_reject_removed_scheduling_fields() {
let result = serde_json::from_value::<CreateProviderInstanceRequest>(json!({
"id": "inst_openai",
"expectedConfigRevision": 1,
"providerKind": "openai",
"name": "OpenAI",
"baseUrl": "https://chatgpt.com",
"maxConcurrency": 3
}));
assert!(result.is_err());
}
#[test]
fn provider_instance_should_reject_removed_model_routing_fields() {
let result = serde_json::from_value::<CreateProviderInstanceRequest>(json!({
"id": "inst_openai",
"expectedConfigRevision": 1,
"providerKind": "openai",
"name": "OpenAI",
"baseUrl": "https://chatgpt.com",
"upstreamModelId": "gpt-5.4"
}));
assert!(result.is_err());
}
#[test]
fn provider_instance_should_reject_unknown_provider_kind() {
let request = provider_request("unknown");
assert_eq!(request.validate().unwrap_err().field(), "providerKind");
}
#[test]
fn provider_instance_should_reject_id_without_core_prefix() {
let request: CreateProviderInstanceRequest = serde_json::from_value(json!({
"id": "codex-official",
"expectedConfigRevision": 1,
"providerKind": "openai",
"name": "Codex",
"baseUrl": "https://chatgpt.com/backend-api"
}))
.expect("decode provider instance");
assert_eq!(request.validate().unwrap_err().field(), "id");
}
#[test]
fn provider_instance_should_require_positive_revision() {
let mut request = provider_request("openai");
request.expected_config_revision = 0;
assert_eq!(
request.validate().unwrap_err().field(),
"expectedConfigRevision"
);
}
#[test]
fn catalog_page_size_should_remain_bounded() {
let query = CatalogListQuery {
cursor: None,
limit: Some(201),
};
assert_eq!(query.validate().unwrap_err().field(), "limit");
}
#[test]
fn provider_instance_view_should_not_emit_provider_config_json() {
let value = serde_json::to_value(ProviderInstanceView {
id: "inst_openai".into(),
provider_kind: "openai".into(),
name: "Codex".into(),
base_url: "https://chatgpt.com".into(),
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
})
.expect("serialize provider instance");
assert!(value.get("configJson").is_none());
}
#[test]
fn provider_instance_view_should_emit_platform_explicitly() {
let value = serde_json::to_value(ProviderInstanceView {
id: "inst_xai".into(),
provider_kind: "xai".into(),
name: "xAI".into(),
base_url: "https://grok.com".into(),
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
})
.expect("serialize provider instance");
assert_eq!(value["providerKind"], "xai");
}
+5 -61
View File
@@ -14,15 +14,10 @@ use gateway_admin::{
model::{
MutationContext, Revision,
accounts::{
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccount,
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccounts,
SetAccountEnabled,
},
auth::{AdminAuditEvent, AdminSession},
catalog::{
CreateProviderInstance, DeleteProviderInstance, ProviderInstanceCatalog,
ProviderInstanceDetail, ProviderInstanceMutation, SetProviderInstanceEnabled,
UpdateProviderInstance,
},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -51,7 +46,7 @@ use gateway_admin::{
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
AuthStore, CatalogStore, ClientKeyStore, ObservabilityStore, SettingsStore,
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
},
system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations,
@@ -68,21 +63,17 @@ use gateway_core::{
error::GatewayError,
policy::{ClientApiKeyId, RateLimits},
routing::{
ConfigRevision, ProviderInstanceId, ProviderKind, PublicModelId, UpstreamModelId,
snapshot::SnapshotControl,
ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId, snapshot::SnapshotControl,
},
};
mod accounts;
mod auth;
mod catalog;
mod client_keys;
mod observability;
mod openai;
mod settings;
mod system;
mod wire;
mod xai;
pub(super) struct AdminTestFixture {
pub services: AdminServices,
@@ -104,7 +95,6 @@ impl AdminTestFixture {
let stores = AdminStorePorts::new(
unused.clone(),
auth.clone(),
unused.clone(),
client_keys.clone(),
unused,
settings.clone(),
@@ -515,9 +505,9 @@ impl AccountStore for UnusedStore {
Err(unavailable("account enabled"))
}
async fn delete_account(
async fn delete_accounts(
&self,
_: DeleteAccount,
_: DeleteAccounts,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("account delete"))
@@ -532,52 +522,6 @@ impl AccountStore for UnusedStore {
}
}
#[async_trait]
impl CatalogStore for UnusedStore {
async fn list_provider_instances(&self, _: bool) -> AdminStoreResult<ProviderInstanceCatalog> {
Err(unavailable("provider catalog"))
}
async fn load_provider_instance(
&self,
_: &ProviderInstanceId,
) -> AdminStoreResult<Option<ProviderInstanceDetail>> {
Err(unavailable("provider instance"))
}
async fn create_provider_instance(
&self,
_: CreateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unavailable("provider create"))
}
async fn update_provider_instance(
&self,
_: UpdateProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unavailable("provider update"))
}
async fn set_provider_instance_enabled(
&self,
_: SetProviderInstanceEnabled,
_: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
Err(unavailable("provider enabled"))
}
async fn delete_provider_instance(
&self,
_: DeleteProviderInstance,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable("provider delete"))
}
}
#[async_trait]
impl ObservabilityStore for UnusedStore {
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
@@ -289,7 +289,6 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
client_transport: "http_sse".to_owned(),
requested_model_id: "grok-4.5".to_owned(),
input_token_estimate: 1,
provider_instance_id: None,
provider_kind: Some("xai".to_owned()),
provider_account_ref: None,
provider_account_name: None,
@@ -1,202 +0,0 @@
use chrono::{TimeZone, Utc};
use gateway_admin::model::{
Revision,
accounts::{AccountAvailability, AccountRecord},
};
use gateway_api::admin::openai::{
CodexCredentialView, CompleteOAuthAuthorizationRequest, CredentialDetailsQuery,
CredentialMutationRequest, ImportCredentialsDocumentRequest, ListCredentialsQuery,
RotateCredentialRequest, StartOAuthAuthorizationRequest,
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
fn compact_jwt() -> String {
"header.payload.signature".to_owned()
}
#[test]
fn document_import_accepts_provider_owned_object() {
let request: ImportCredentialsDocumentRequest = serde_json::from_value(serde_json::json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_openai",
"document": {"accounts": [{"token": compact_jwt()}]}
}))
.expect("deserialize Provider import document");
assert_eq!(request.validate(), Ok(()));
}
#[test]
fn document_import_rejects_unknown_outer_fields() {
let result = serde_json::from_value::<ImportCredentialsDocumentRequest>(serde_json::json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_openai",
"document": {"accounts": []},
"maxConcurrency": 2
}));
assert!(result.is_err());
}
#[test]
fn document_import_rejects_non_object_document() {
let array: ImportCredentialsDocumentRequest = serde_json::from_value(serde_json::json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_openai",
"document": []
}))
.expect("deserialize array document");
assert_eq!(array.validate().unwrap_err().field(), "document");
}
#[test]
fn document_import_keeps_provider_document_opaque_to_api() {
let request: ImportCredentialsDocumentRequest = serde_json::from_value(serde_json::json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_openai",
"document": {
"providerOwnedUnknownField": {"nested": [1, 2, 3]},
"accounts": [{"credentials": {"access_token": "provider-validates-this"}}]
}
}))
.expect("deserialize opaque Provider document");
assert_eq!(request.validate(), Ok(()));
}
#[test]
fn list_query_should_validate_status_pagination_and_reserved_ids() {
let valid: ListCredentialsQuery = serde_json::from_value(serde_json::json!({
"providerInstanceId": "inst_openai",
"availability": "cooldown",
"enabled": false,
"cursor": "cursor",
"limit": 200
}))
.expect("deserialize list query");
let invalid_status: ListCredentialsQuery = serde_json::from_value(serde_json::json!({
"availability": "refreshing"
}))
.expect("deserialize invalid status query");
let invalid_limit: ListCredentialsQuery = serde_json::from_value(serde_json::json!({
"limit": 201
}))
.expect("deserialize invalid page size query");
assert_eq!(valid.validate(), Ok(()));
assert_eq!(
invalid_status.validate().unwrap_err().field(),
"availability"
);
assert_eq!(invalid_limit.validate().unwrap_err().field(), "limit");
}
#[test]
fn detail_and_mutation_should_reject_missing_invalid_or_overflowing_identity() {
let detail = CredentialDetailsQuery {
credential_id: "__reserved__".to_owned(),
};
let mutation = CredentialMutationRequest {
credential_id: "cred_codex".to_owned(),
expected_config_revision: 0,
};
let rotation: RotateCredentialRequest = serde_json::from_value(serde_json::json!({
"credentialId": "cred_codex",
"expectedConfigRevision": 1,
"expectedCredentialRevision": u64::MAX,
"accessToken": compact_jwt()
}))
.expect("deserialize overflowing revision");
assert_eq!(detail.validate().unwrap_err().field(), "credentialId");
assert_eq!(
mutation.validate().unwrap_err().field(),
"expectedConfigRevision"
);
assert_eq!(
rotation.validate().unwrap_err().field(),
"expectedCredentialRevision"
);
}
#[test]
fn oauth_wire_should_keep_flow_id_and_callback_in_post_body_only() {
let start: StartOAuthAuthorizationRequest = serde_json::from_value(serde_json::json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_openai",
"name": "OAuth browser flow",
"credentialId": "acct_existing",
"expectedCredentialRevision": 9
}))
.expect("start wire");
let complete: CompleteOAuthAuthorizationRequest = serde_json::from_value(serde_json::json!({
"flowId": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"callbackUrl": "http://localhost:1455/auth/callback?code=code&state=state"
}))
.expect("complete wire");
let client_state =
serde_json::from_value::<StartOAuthAuthorizationRequest>(serde_json::json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_openai",
"name": "OAuth browser flow",
"state": "client-controlled"
}))
.expect_err("state must remain server-side");
assert_eq!(start.validate(), Ok(()));
assert_eq!(complete.validate(), Ok(()));
assert!(client_state.to_string().contains("unknown field `state`"));
}
#[test]
fn oauth_reauthorization_requires_account_and_revision_together() {
let missing_revision: StartOAuthAuthorizationRequest =
serde_json::from_value(serde_json::json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_openai",
"name": "OAuth browser flow",
"credentialId": "acct_existing"
}))
.expect("deserialize incomplete reauthorization");
assert_eq!(
missing_revision.validate().unwrap_err().field(),
"reauthorization"
);
}
#[test]
fn credential_result_should_map_to_the_existing_safe_wire_view() {
let timestamp = Utc
.with_ymd_and_hms(2026, 7, 18, 3, 0, 0)
.single()
.expect("valid fixture timestamp");
let view = CodexCredentialView::from(AccountRecord {
id: "acct_openai_safe".to_owned(),
provider_instance_id: ProviderInstanceId::new("inst_openai").expect("provider instance ID"),
provider_kind: ProviderKind::new("openai").expect("provider kind"),
name: "Codex OAuth".to_owned(),
email: Some("verified@example.com".to_owned()),
upstream_user_id: "subject_openai".to_owned(),
upstream_account_id: None,
plan_type: Some("pro".to_owned()),
credential_revision: Revision::new(2).expect("credential revision"),
has_refresh_token: true,
access_token_expires_at: timestamp,
next_refresh_at: Some(timestamp),
enabled: true,
availability: AccountAvailability::Banned,
availability_reason: Some("account disabled".to_owned()),
cooldown_until: None,
availability_observed_at: timestamp,
quota_observed_at: None,
created_at: timestamp,
updated_at: timestamp,
});
let rendered = serde_json::to_value(view).expect("serialize safe credential view");
assert_eq!(rendered["availability"], "invalid");
assert_eq!(rendered["credentialRevision"], 2);
assert!(rendered.get("accessToken").is_none());
}
@@ -117,7 +117,7 @@ async fn settings_post_should_replace_provider_scoped_model_mappings() {
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings",
"/api/admin/settings/update",
Some(update_body()),
))
.await
@@ -196,7 +196,7 @@ async fn admin_key_delete_should_use_fixed_post_path() {
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/admin-api-key",
"/api/admin/settings/admin-api-key/delete",
None,
))
.await
@@ -1,208 +0,0 @@
use chrono::{TimeZone, Utc};
use gateway_admin::model::{
Revision,
accounts::{AccountAvailability, AccountRecord},
};
use gateway_api::admin::xai::{
AuthorizationStartData, CompleteAuthorizationRequest, NewCredentialRequest,
StartAuthorizationRequest, XaiCredentialImportData, XaiCredentialImportDocumentRequest,
XaiCredentialListData, XaiCredentialViewData,
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use serde_json::{Value, json};
fn valid_request() -> Value {
json!({
"expectedConfigRevision": 7,
"providerInstanceId": "inst_xai",
"document": {
"type": "oauth-account-bundle",
"version": 1,
"exported_at": "2026-07-18T02:47:01Z",
"proxies": [],
"accounts": [{
"name": "Grok OAuth",
"platform": "grok",
"type": "oauth",
"credentials": {
"access_token": "access-token-secret",
"refresh_token": "refresh-token-secret",
"token_type": "Bearer",
"expires_at": "2026-07-18T06:47:01Z",
"email": "ignored@example.com",
"base_url": "https://cli-chat-proxy.grok.com/v1",
"id_token": "header.payload.signature",
"client_id": "official-client",
"scope": "openid offline_access grok-cli:access api:access"
},
"extra": {"email": "also-ignored@example.com"},
"concurrency": 1,
"priority": 0
}]
}
})
}
#[test]
fn import_document_accepts_opaque_provider_owned_shape() {
let request: XaiCredentialImportDocumentRequest =
serde_json::from_value(valid_request()).expect("deserialize Provider document");
request
.validate()
.expect("validate Provider document boundary");
}
#[test]
fn import_document_keeps_inner_schema_owned_by_provider() {
let mut request = valid_request();
request["document"]["accounts"][0]["credentials"]["provider_future_field"] =
json!({"nested": true});
let request = serde_json::from_value::<XaiCredentialImportDocumentRequest>(request)
.expect("API must not decode Provider fields");
assert_eq!(request.validate(), Ok(()));
}
#[test]
fn import_document_rejects_unknown_outer_field() {
let mut request = valid_request();
request["schemaVersion"] = json!(1);
let error = serde_json::from_value::<XaiCredentialImportDocumentRequest>(request)
.err()
.expect("unknown API field must fail");
assert!(error.to_string().contains("unknown field `schemaVersion`"));
}
#[test]
fn import_document_rejects_non_object_document() {
let mut request = valid_request();
request["document"] = json!([]);
let request: XaiCredentialImportDocumentRequest =
serde_json::from_value(request).expect("deserialize generic JSON");
assert_eq!(request.validate().unwrap_err().field(), "document");
}
#[test]
fn import_response_should_never_contain_identity_or_secret_fields() {
let response = XaiCredentialImportData::new(8, vec!["cred_xai_safe".to_owned()]);
let rendered = serde_json::to_string(&response).expect("serialize response");
assert_eq!(
rendered,
r#"{"configRevision":8,"importedCount":1,"credentialIds":["cred_xai_safe"]}"#
);
}
#[test]
fn credential_list_wire_should_expose_only_safe_view_fields() {
let timestamp = Utc
.with_ymd_and_hms(2026, 7, 18, 3, 0, 0)
.single()
.expect("valid fixture timestamp");
let response = XaiCredentialListData {
config_revision: 8,
items: vec![XaiCredentialViewData {
id: "cred_xai_safe".to_owned(),
provider_instance_id: "inst_xai".to_owned(),
name: "Grok OAuth".to_owned(),
email: Some("verified@example.com".to_owned()),
upstream_user_id: "subject_xai".to_owned(),
upstream_account_id: None,
plan_type: Some("pro".to_owned()),
enabled: true,
credential_revision: 2,
has_refresh_token: true,
availability: "ready".to_owned(),
availability_reason: None,
access_token_expires_at: timestamp,
next_refresh_at: Some(timestamp),
cooldown_until: None,
created_at: timestamp,
updated_at: timestamp,
}],
};
let rendered = serde_json::to_string(&response).expect("serialize typed list response");
assert!(rendered.contains("credentialRevision"));
assert!(rendered.contains("accessTokenExpiresAt"));
for forbidden in [
"\"accessToken\":",
"\"refreshToken\":",
"\"idToken\":",
"\"subject\":",
] {
assert!(!rendered.contains(forbidden), "wire leaked {forbidden}");
}
}
#[test]
fn authorization_start_wire_contains_only_browser_flow_fields() {
let response = AuthorizationStartData {
flow_id: "flow_xai_safe".to_owned(),
authorization_url: "https://auth.x.ai/oauth2/auth?state=redacted".to_owned(),
expires_at: Utc::now(),
};
let rendered = serde_json::to_string(&response).expect("serialize authorization response");
assert!(rendered.contains("authorizationUrl"));
assert!(!rendered.contains("codeVerifier"));
assert!(!rendered.contains("nonce"));
}
#[test]
fn authorization_requests_require_server_owned_flow_and_full_callback_url() {
let start = StartAuthorizationRequest {
credential: NewCredentialRequest {
expected_config_revision: 7,
provider_instance_id: "inst_xai".to_owned(),
name: "xAI OAuth".to_owned(),
},
};
start.validate().expect("authorization start is valid");
let complete: CompleteAuthorizationRequest = serde_json::from_value(json!({
"flowId": "flow_xai_safe",
"callbackUrl": "http://127.0.0.1:56121/callback?code=redacted&state=redacted"
}))
.expect("deserialize callback completion");
complete.validate().expect("callback completion is valid");
}
#[test]
fn credential_result_should_map_to_the_existing_safe_wire_view() {
let timestamp = Utc
.with_ymd_and_hms(2026, 7, 18, 3, 0, 0)
.single()
.expect("valid fixture timestamp");
let view = XaiCredentialViewData::from(AccountRecord {
id: "acct_xai_safe".to_owned(),
provider_instance_id: ProviderInstanceId::new("inst_xai").expect("provider instance ID"),
provider_kind: ProviderKind::new("xai").expect("provider kind"),
name: "Grok OAuth".to_owned(),
email: Some("verified@example.com".to_owned()),
upstream_user_id: "subject_xai".to_owned(),
upstream_account_id: None,
plan_type: Some("pro".to_owned()),
credential_revision: Revision::new(2).expect("credential revision"),
has_refresh_token: true,
access_token_expires_at: timestamp,
next_refresh_at: Some(timestamp),
enabled: true,
availability: AccountAvailability::QuotaExhausted,
availability_reason: None,
cooldown_until: None,
availability_observed_at: timestamp,
quota_observed_at: None,
created_at: timestamp,
updated_at: timestamp,
});
let rendered = serde_json::to_value(view).expect("serialize safe credential view");
assert_eq!(rendered["availability"], "quota_exhausted");
assert_eq!(rendered["credentialRevision"], 2);
assert!(rendered.get("refreshToken").is_none());
}
@@ -35,15 +35,12 @@ fn source_tree_should_match_frozen_machine_manifest() {
let mut expected = vec![
"src/admin/accounts.rs",
"src/admin/auth.rs",
"src/admin/catalog.rs",
"src/admin/client_keys.rs",
"src/admin/openai.rs",
"src/admin/mod.rs",
"src/admin/observability.rs",
"src/admin/settings.rs",
"src/admin/system.rs",
"src/admin/wire.rs",
"src/admin/xai.rs",
"src/health.rs",
"src/lib.rs",
"src/openai/auth.rs",
@@ -72,15 +69,12 @@ fn test_tree_should_match_frozen_rust_mirror() {
let mut expected = vec![
"tests/admin/accounts.rs",
"tests/admin/auth.rs",
"tests/admin/catalog.rs",
"tests/admin/client_keys.rs",
"tests/admin/openai.rs",
"tests/admin/mod.rs",
"tests/admin/observability.rs",
"tests/admin/settings.rs",
"tests/admin/system.rs",
"tests/admin/wire.rs",
"tests/admin/xai.rs",
"tests/architecture.rs",
"tests/health.rs",
"tests/main.rs",
@@ -128,7 +128,7 @@ fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() {
gateway_error_from_engine(&error),
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream Provider instance or account is currently available"
"no upstream provider is currently available for this request"
)
);
}
+7 -15
View File
@@ -34,8 +34,8 @@ use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLif
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
use gateway_core::routing::{
ConfigRevision, InstanceHealth, ModelCapabilities, ProviderInstance, ProviderInstanceId,
ProviderKind, ProviderModel, RuntimeSnapshot, UpstreamModelId,
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, RuntimeSnapshot,
UpstreamModelId,
};
pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Router {
@@ -80,8 +80,6 @@ pub(super) fn authenticated_client_for_provider(
fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
let provider = ProviderKind::new(provider_name).expect("provider");
let instance_id =
ProviderInstanceId::new(format!("inst_{provider_name}_api_test")).expect("instance");
let capabilities = ModelCapabilities::new(
BTreeSet::from([gateway_core::operation::OperationKind::Generate]),
128_000,
@@ -94,18 +92,12 @@ fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
NonZeroU32::new(2).expect("concurrency"),
Duration::from_millis(1),
),
vec![ProviderInstance::new(
instance_id.clone(),
provider.clone(),
"https://api.example.invalid".to_owned(),
true,
InstanceHealth::Healthy,
)],
vec![provider.clone()],
["model-a", "model-b"]
.into_iter()
.map(|model| {
ProviderModel::new(
instance_id.clone(),
provider.clone(),
UpstreamModelId::new(model).expect("model"),
capabilities.clone(),
)
@@ -230,21 +222,21 @@ struct UnusedCircuits;
impl ProviderCircuitPort for UnusedCircuits {
fn decision<'a>(
&'a self,
_: &'a ProviderInstanceId,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
fn observe_failure<'a>(
&'a self,
_: &'a ProviderInstanceId,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
fn observe_success<'a>(
&'a self,
_: &'a ProviderInstanceId,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("authentication fixture does not execute") })
}
@@ -10,7 +10,7 @@ use futures::future::BoxFuture;
use crate::engine::credential::ProviderAccountId;
use crate::error::{IdentifierError, SafeUpstreamValue, validate_text};
use crate::policy::ClientApiKeyId;
use crate::routing::{ProviderInstanceId, ProviderKind};
use crate::routing::ProviderKind;
/// 客户端传入的 previous response ID。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -46,7 +46,7 @@ pub enum NativeContinuationScope {
/// 外层已解析并认证的 native previous-response pin。
///
/// 该值不代表数据库 transcript;它只阻止 native handle 被发送到错误的
/// Provider、instance 或账号。
/// Provider 或账号。
#[derive(Clone, PartialEq, Eq)]
pub struct NativeContinuationPin {
/// 客户端提交、仅供 Store 在调用方隔离下查找的网关 response ID。
@@ -54,7 +54,6 @@ pub struct NativeContinuationPin {
/// Store 从已提交成功请求解析出的 Provider 原生 response handle。
upstream_response_id: SafeUpstreamValue,
provider: ProviderKind,
instance: ProviderInstanceId,
account: ProviderAccountId,
scope: NativeContinuationScope,
}
@@ -65,14 +64,12 @@ impl NativeContinuationPin {
previous_response_id: PreviousResponseId,
upstream_response_id: SafeUpstreamValue,
provider: ProviderKind,
instance: ProviderInstanceId,
account: ProviderAccountId,
) -> Self {
Self {
previous_response_id,
upstream_response_id,
provider,
instance,
account,
scope: NativeContinuationScope::ConnectionLocal,
}
@@ -101,11 +98,6 @@ impl NativeContinuationPin {
&self.provider
}
#[must_use]
pub const fn instance(&self) -> &ProviderInstanceId {
&self.instance
}
#[must_use]
pub const fn account(&self) -> &ProviderAccountId {
&self.account
@@ -118,13 +110,8 @@ impl NativeContinuationPin {
/// 校验本次 route/account 选择没有破坏 native pin。
#[must_use]
pub fn matches(
&self,
provider: &ProviderKind,
instance: &ProviderInstanceId,
account: &ProviderAccountId,
) -> bool {
self.provider == *provider && self.instance == *instance && self.account == *account
pub fn matches(&self, provider: &ProviderKind, account: &ProviderAccountId) -> bool {
self.provider == *provider && self.account == *account
}
}
@@ -135,7 +122,6 @@ impl fmt::Debug for NativeContinuationPin {
.field("previous_response_id", &"<redacted>")
.field("upstream_response_id", &"<redacted>")
.field("provider", &self.provider)
.field("instance", &self.instance)
.field("account", &self.account)
.field("scope", &self.scope)
.finish()
@@ -1,4 +1,4 @@
//! 唯一的账号重试、Provider instance 切换、发送与下游 commit barrier owner。
//! 唯一的账号重试、发送与下游 commit barrier owner。
use std::collections::{BTreeSet, VecDeque};
use std::num::NonZeroU32;
@@ -89,8 +89,6 @@ where
commit_policy,
account_state_owner,
cancellation,
candidate_index: 0,
last_attempt_candidate_index: None,
attempts: 0,
excluded_accounts: BTreeSet::new(),
credential_recovery_attempted_accounts: BTreeSet::new(),
@@ -151,7 +149,7 @@ enum StreamCommitPolicy {
/// API 可逐事件消费的 Core 执行会话。
///
/// API 只能提交下游 delivery 边界;账号重试、Provider instance 切换、断流终结与
/// API 只能提交下游 delivery 边界;账号重试、断流终结与
/// `model_requests` 写回均留在本类型内。
pub struct ResponseExecutionSession<S: ?Sized> {
engine: Arc<GatewayEngine<S>>,
@@ -167,8 +165,6 @@ pub struct ResponseExecutionSession<S: ?Sized> {
commit_policy: StreamCommitPolicy,
account_state_owner: Option<ProviderAccountStateOwner>,
cancellation: CancellationToken,
candidate_index: usize,
last_attempt_candidate_index: Option<usize>,
attempts: u32,
excluded_accounts: BTreeSet<crate::engine::credential::ProviderAccountId>,
credential_recovery_attempted_accounts: BTreeSet<crate::engine::credential::ProviderAccountId>,
@@ -443,220 +439,199 @@ where
}
async fn prepare_attempt(&mut self) -> Result<(), EngineError> {
loop {
if self.attempts >= self.plan.max_attempts().get() {
return Err(EngineError::EmptyRoutingPlan);
}
let Some(candidate) = self.plan.candidates().get(self.candidate_index).cloned() else {
let error = GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream account or Provider instance is available",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::EmptyRoutingPlan);
};
let next_attempt = self
.attempts
.checked_add(1)
.and_then(NonZeroU32::new)
.ok_or(EngineError::EmptyRoutingPlan)?;
let context = AttemptContext::new(
RequestAttemptContext::new(
self.request_id.clone(),
self.client_api_key_ref.clone(),
),
next_attempt,
self.deadline,
self.plan.account_selection_policy(),
AccountAttemptContext::new(
self.excluded_accounts.clone(),
self.required_account.clone(),
self.account_state_owner.clone(),
)
.with_credential_recovery_attempted(
self.required_account.as_ref().is_some_and(|account| {
self.credential_recovery_attempted_accounts
.contains(account)
}),
),
self.continuation.clone(),
self.cancellation.clone(),
)
.with_continuation_attempt(self.continuation_attempt);
let trigger = if self.attempts == 0 {
AttemptTrigger::Initial
} else if self.last_attempt_candidate_index == Some(self.candidate_index) {
AttemptTrigger::AccountRetry
} else {
AttemptTrigger::InstanceFallback
};
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
let provider_request = ProviderRequest::new(self.operation.clone(), candidate.clone());
let stream = match poll_provider(
provider,
provider_request,
context,
self.cancellation.clone(),
self.deadline,
)
.await
{
ProviderBoundary::Cancelled => {
self.finish_interruption(EngineError::Cancelled).await?;
return Err(EngineError::Cancelled);
}
ProviderBoundary::Deadline => {
self.record_provider_failure(
candidate.instance().clone(),
ProviderErrorKind::Timeout,
);
self.finish_interruption(EngineError::Deadline).await?;
return Err(EngineError::Deadline);
}
ProviderBoundary::Result(result) => match *result {
Ok(stream) => stream,
Err(error) => {
if error.kind() != ProviderErrorKind::Unavailable
|| error.send_state() != UpstreamSendState::NotSent
{
self.record_provider_failure(
candidate.instance().clone(),
error.kind(),
);
}
let provider_proved_replay_safe = provider_proved_replay_safe(&error);
if self.can_fallback_instance(provider_proved_replay_safe) {
self.candidate_index += 1;
continue;
}
let engine_error = provider_engine_error(&error);
self.finish_provider_error(&error).await?;
return Err(engine_error);
}
},
};
if !stream.metadata().confirms(&candidate) {
self.record_provider_failure(
candidate.instance().clone(),
ProviderErrorKind::Protocol,
);
let error = GatewayError::new(
GatewayErrorKind::Internal,
"provider metadata did not match the frozen candidate",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::ProviderMetadataMismatch);
}
let metadata = stream.metadata().clone();
if self
.required_account
.as_ref()
.is_some_and(|required| metadata.provider_account_id() != Some(required))
{
let error = GatewayError::new(
GatewayErrorKind::Internal,
"provider did not use the required account",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::RequiredAccountMismatch);
}
if let Some(pin) = self
.continuation
.as_ref()
.and_then(ContinuationBinding::pinned)
&& self.continuation_attempt == ContinuationAttempt::Native
&& !metadata.provider_account_id().is_some_and(|account| {
pin.matches(metadata.provider(), metadata.instance(), account)
})
{
let error = GatewayError::new(
GatewayErrorKind::Internal,
"native continuation binding did not match selected account",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::ContinuationPinMismatch);
}
if self.account_state_owner.is_none()
&& let Some(account) = metadata.provider_account_id()
{
self.account_state_owner = Some(ProviderAccountStateOwner::new(
metadata.provider().clone(),
metadata.instance().clone(),
account.clone(),
));
}
self.engine
.store()
.record_attempt(AttemptRecord {
request_id: self.request_id.clone(),
attempt_count: next_attempt,
trigger,
provider_instance_id: metadata.instance().clone(),
provider_kind: metadata.provider().clone(),
provider_account_id: metadata.provider_account_id().cloned(),
provider_account_ref: metadata.provider_account_id().cloned(),
upstream_model_id: metadata.upstream_model().clone(),
upstream_transport: metadata.transport().as_str().to_owned(),
http_version: None,
})
.await?;
self.attempts = next_attempt.get();
self.last_attempt_candidate_index = Some(self.candidate_index);
self.current = Some(CurrentAttempt {
stream,
metadata,
trigger,
index: next_attempt,
started_at: SystemTime::now(),
send_observed: false,
response_observation: None,
});
return Ok(());
if self.attempts >= self.plan.max_attempts().get() {
return Err(EngineError::EmptyRoutingPlan);
}
let Some(candidate) = self.plan.candidates().first().cloned() else {
let error = GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream Provider is available",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::EmptyRoutingPlan);
};
let next_attempt = self
.attempts
.checked_add(1)
.and_then(NonZeroU32::new)
.ok_or(EngineError::EmptyRoutingPlan)?;
let context = AttemptContext::new(
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone()),
next_attempt,
self.deadline,
self.plan.account_selection_policy(),
AccountAttemptContext::new(
self.excluded_accounts.clone(),
self.required_account.clone(),
self.account_state_owner.clone(),
)
.with_credential_recovery_attempted(
self.required_account.as_ref().is_some_and(|account| {
self.credential_recovery_attempted_accounts
.contains(account)
}),
),
self.continuation.clone(),
self.cancellation.clone(),
)
.with_continuation_attempt(self.continuation_attempt);
let trigger = if self.attempts == 0 {
AttemptTrigger::Initial
} else {
AttemptTrigger::AccountRetry
};
let provider = self
.engine
.providers()
.get(candidate.provider())
.cloned()
.ok_or_else(|| EngineError::ProviderNotRegistered {
provider: candidate.provider().as_str().to_owned(),
})?;
let provider_request = ProviderRequest::new(self.operation.clone(), candidate.clone());
let stream = match poll_provider(
provider,
provider_request,
context,
self.cancellation.clone(),
self.deadline,
)
.await
{
ProviderBoundary::Cancelled => {
self.finish_interruption(EngineError::Cancelled).await?;
return Err(EngineError::Cancelled);
}
ProviderBoundary::Deadline => {
self.record_provider_failure(
candidate.provider().clone(),
ProviderErrorKind::Timeout,
);
self.finish_interruption(EngineError::Deadline).await?;
return Err(EngineError::Deadline);
}
ProviderBoundary::Result(result) => match *result {
Ok(stream) => stream,
Err(error) => {
if error.kind() != ProviderErrorKind::Unavailable
|| error.send_state() != UpstreamSendState::NotSent
{
self.record_provider_failure(candidate.provider().clone(), error.kind());
}
let engine_error = provider_engine_error(&error);
self.finish_provider_error(&error).await?;
return Err(engine_error);
}
},
};
if !stream.metadata().confirms(&candidate) {
self.record_provider_failure(candidate.provider().clone(), ProviderErrorKind::Protocol);
let error = GatewayError::new(
GatewayErrorKind::Internal,
"provider metadata did not match the frozen candidate",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::ProviderMetadataMismatch);
}
let metadata = stream.metadata().clone();
if self
.required_account
.as_ref()
.is_some_and(|required| metadata.provider_account_id() != Some(required))
{
let error = GatewayError::new(
GatewayErrorKind::Internal,
"provider did not use the required account",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::RequiredAccountMismatch);
}
if let Some(pin) = self
.continuation
.as_ref()
.and_then(ContinuationBinding::pinned)
&& self.continuation_attempt == ContinuationAttempt::Native
&& !metadata
.provider_account_id()
.is_some_and(|account| pin.matches(metadata.provider(), account))
{
let error = GatewayError::new(
GatewayErrorKind::Internal,
"native continuation binding did not match selected account",
);
self.finish_failure(FailureFinalization {
outcome: ExecutionOutcome::Failed,
send_state: self.current_send_state(),
error,
upstream_status_code: None,
provider_error_code: None,
retry_after_ms: None,
provider_response_id: None,
})
.await?;
return Err(EngineError::ContinuationPinMismatch);
}
if self.account_state_owner.is_none()
&& let Some(account) = metadata.provider_account_id()
{
self.account_state_owner = Some(ProviderAccountStateOwner::new(
metadata.provider().clone(),
account.clone(),
));
}
self.engine
.store()
.record_attempt(AttemptRecord {
request_id: self.request_id.clone(),
attempt_count: next_attempt,
trigger,
provider_kind: metadata.provider().clone(),
provider_account_id: metadata.provider_account_id().cloned(),
provider_account_ref: metadata.provider_account_id().cloned(),
upstream_model_id: metadata.upstream_model().clone(),
upstream_transport: metadata.transport().as_str().to_owned(),
http_version: None,
})
.await?;
self.attempts = next_attempt.get();
self.current = Some(CurrentAttempt {
stream,
metadata,
trigger,
index: next_attempt,
started_at: SystemTime::now(),
send_observed: false,
response_observation: None,
});
Ok(())
}
async fn observe_event(&mut self, event: &GatewayEvent) -> Result<(), EngineError> {
@@ -757,7 +732,7 @@ where
/// 返回 `true` 表示调用方必须丢弃本 attempt 已收集的未提交事件。
async fn handle_stream_error(&mut self, error: ProviderError) -> Result<bool, EngineError> {
let current = self.current.take().ok_or(EngineError::EmptyRoutingPlan)?;
self.record_provider_failure(current.metadata.instance().clone(), error.kind());
self.record_provider_failure(current.metadata.provider().clone(), error.kind());
let send_state = if current.send_observed {
UpstreamSendState::Sent
} else {
@@ -810,18 +785,12 @@ where
{
self.excluded_accounts.insert(account.clone());
}
if error.kind() == ProviderErrorKind::Unsupported
&& self.can_fallback_instance(provider_proved_replay_safe)
{
self.candidate_index += 1;
}
self.engine
.store()
.record_intermediate_failure(IntermediateFailure {
request_id: self.request_id.clone(),
attempt_index: current.index,
trigger: current.trigger,
instance_id: current.metadata.instance().clone(),
provider_kind: current.metadata.provider().clone(),
account_id: current.metadata.provider_account_id().cloned(),
upstream_model_id: current.metadata.upstream_model().clone(),
@@ -907,29 +876,6 @@ where
self.before_commit.clear();
}
fn can_fallback_instance(&self, provider_proved_replay_safe: bool) -> bool {
if self.required_account.is_some()
|| self.candidate_index + 1 >= self.plan.candidates().len()
{
return false;
}
let next = &self.plan.candidates()[self.candidate_index + 1];
match self.continuation_attempt {
ContinuationAttempt::None => {
self.operation.retry_safety() == RetrySafety::Idempotent
|| provider_proved_replay_safe
}
ContinuationAttempt::ReplayAny => {
provider_proved_replay_safe
&& self
.operation
.provider_session_state(next.provider().as_str())
.is_some()
}
ContinuationAttempt::Native | ContinuationAttempt::ReplayOwner => false,
}
}
async fn finish_success(&mut self) -> Result<(), EngineError> {
if self.finalized {
return Ok(());
@@ -1172,36 +1118,34 @@ where
}
fn record_current_provider_success(&mut self) {
let instance_id = self
let provider_kind = self
.current
.as_ref()
.map(|current| current.metadata.instance().clone());
if let Some(instance_id) = instance_id {
.map(|current| current.metadata.provider().clone());
if let Some(provider_kind) = provider_kind {
self.provider_attempt_outcomes
.push(ProviderAttemptOutcome::Succeeded {
provider_instance_id: instance_id,
});
.push(ProviderAttemptOutcome::Succeeded { provider_kind });
}
}
fn record_current_provider_failure(&mut self, error_kind: ProviderErrorKind) {
let instance_id = self
let provider_kind = self
.current
.as_ref()
.map(|current| current.metadata.instance().clone());
if let Some(instance_id) = instance_id {
self.record_provider_failure(instance_id, error_kind);
.map(|current| current.metadata.provider().clone());
if let Some(provider_kind) = provider_kind {
self.record_provider_failure(provider_kind, error_kind);
}
}
fn record_provider_failure(
&mut self,
provider_instance_id: crate::routing::ProviderInstanceId,
provider_kind: crate::routing::ProviderKind,
error_kind: ProviderErrorKind,
) {
self.provider_attempt_outcomes
.push(ProviderAttemptOutcome::Failed {
provider_instance_id,
provider_kind,
error_kind,
});
}
@@ -11,7 +11,7 @@ use serde_json::{Map, Value};
use thiserror::Error;
use crate::error::{IdentifierError, StoreError, validate_text};
use crate::routing::{ProviderInstanceId, ProviderKind};
use crate::routing::ProviderKind;
/// `provider_accounts.id` 的核心值对象。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -192,7 +192,6 @@ impl AccountAvailability {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccount {
id: ProviderAccountId,
instance: ProviderInstanceId,
provider: ProviderKind,
name: String,
email: Option<String>,
@@ -209,11 +208,10 @@ pub struct ProviderAccount {
}
impl ProviderAccount {
/// 创建账号快照;Provider 身份一致性由数据库复合 FK 保证。
/// 创建账号快照。
#[must_use]
pub const fn new(
id: ProviderAccountId,
instance: ProviderInstanceId,
provider: ProviderKind,
name: String,
upstream_user_id: String,
@@ -222,7 +220,6 @@ impl ProviderAccount {
) -> Self {
Self {
id,
instance,
provider,
name,
email: None,
@@ -281,11 +278,6 @@ impl ProviderAccount {
&self.id
}
#[must_use]
pub const fn instance(&self) -> &ProviderInstanceId {
&self.instance
}
#[must_use]
pub const fn provider(&self) -> &ProviderKind {
&self.provider
@@ -585,9 +577,9 @@ pub trait ProviderAccountStore: Send + Sync {
async fn list_accounts(&self) -> Result<Vec<ProviderAccount>, StoreError>;
async fn list_for_instance(
async fn list_for_provider(
&self,
instance: &ProviderInstanceId,
provider: &ProviderKind,
) -> Result<Vec<ProviderAccount>, StoreError>;
async fn load_credential(
@@ -19,15 +19,16 @@ use crate::engine::coordinator::ResponseExecutionSession;
use crate::engine::probe::{AccountProbe, AccountProbeRequest, AccountProbeResult};
use crate::engine::provider::ProviderRegistry;
use crate::engine::{
AttemptCoordinator, CancellationToken, CommitRequirement, CoordinatedEvent, EngineError,
ExecutionStore, GatewayEngine, ModelRequestId, NewModelRequest, ProviderAttemptOutcome,
AttemptCoordinator, AttemptRecord, CancellationToken, CommitRequirement, CoordinatedEvent,
EngineError, ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization,
ModelRequestId, NewModelRequest, ProviderAttemptOutcome, RecoveryReport, UpstreamSendState,
};
use crate::error::{GatewayError, GatewayErrorKind, ProviderErrorKind};
use crate::error::{GatewayError, GatewayErrorKind, ProviderErrorKind, StoreError};
use crate::event::{GatewayEvent, ProviderEvent, ProviderResponseHeader};
use crate::operation::{Operation, ReasoningEffort};
use crate::policy::{ClientApiKeyId, ClientPolicy};
use crate::routing::snapshot::RuntimeSnapshotHandle;
use crate::routing::{ProviderInstanceId, PublicModelId, RoutingContext, RuntimeSnapshot};
use crate::routing::{ProviderKind, PublicModelId, RoutingContext, RuntimeSnapshot};
const MODEL_REQUEST_DEADLINE: Duration = Duration::from_secs(10 * 60);
@@ -156,21 +157,22 @@ pub struct ProviderCircuitError;
pub trait ProviderCircuitPort: Send + Sync {
fn decision<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>>;
fn observe_failure<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>>;
fn observe_success<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>>;
}
pub struct DefaultExecutionService {
snapshots: RuntimeSnapshotHandle,
coordinator: Arc<AttemptCoordinator<dyn ExecutionStore>>,
probe_coordinator: Arc<AttemptCoordinator<dyn ExecutionStore>>,
providers: ProviderRegistry,
admissions: Arc<dyn ClientAdmissionPort>,
circuits: Arc<dyn ProviderCircuitPort>,
@@ -188,9 +190,12 @@ impl DefaultExecutionService {
continuation: Arc<dyn NativeContinuationPort>,
) -> Self {
let engine = GatewayEngine::<dyn ExecutionStore>::new(execution, providers.clone());
let transient: Arc<dyn ExecutionStore> = Arc::new(TransientExecutionStore);
let probe_engine = GatewayEngine::<dyn ExecutionStore>::new(transient, providers.clone());
Self {
snapshots,
coordinator: Arc::new(AttemptCoordinator::new(engine)),
probe_coordinator: Arc::new(AttemptCoordinator::new(probe_engine)),
providers,
admissions,
circuits,
@@ -210,12 +215,7 @@ impl DefaultExecutionService {
})?;
let request_id = new_request_id()?;
let routing_context = self
.route_context(
&request.client.snapshot,
request.client.policy.provider_kind(),
&request.public_model,
&request.operation,
)
.route_context(request.client.policy.provider_kind())
.await?;
let plan = request
.client
@@ -377,50 +377,20 @@ impl DefaultExecutionService {
async fn route_context(
&self,
snapshot: &RuntimeSnapshot,
provider_kind: &crate::routing::ProviderKind,
model: &PublicModelId,
operation: &Operation,
provider_kind: &ProviderKind,
) -> Result<RoutingContext, GatewayError> {
let allowed_instances = snapshot.instance_ids_for_provider(provider_kind);
let preliminary = snapshot
.plan(
model,
operation,
&RoutingContext {
provider_kind: Some(provider_kind.clone()),
allowed_instances: Some(allowed_instances.clone()),
..RoutingContext::default()
},
let blocked_providers = match self.circuits.decision(provider_kind).await.map_err(|_| {
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"provider health state is temporarily unavailable",
)
.map_err(map_routing_error)?;
let mut blocked_instances = BTreeSet::new();
let mut checked = BTreeSet::new();
for candidate in preliminary.candidates() {
if !checked.insert(candidate.instance().clone()) {
continue;
}
match self
.circuits
.decision(candidate.instance())
.await
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"provider health state is temporarily unavailable",
)
})? {
ProviderCircuitDecision::Allow => {}
ProviderCircuitDecision::BlockedUntil(_) => {
blocked_instances.insert(candidate.instance().clone());
}
}
}
})? {
ProviderCircuitDecision::Allow => BTreeSet::new(),
ProviderCircuitDecision::BlockedUntil(_) => BTreeSet::from([provider_kind.clone()]),
};
Ok(RoutingContext {
provider_kind: Some(provider_kind.clone()),
allowed_instances: Some(allowed_instances),
blocked_instances,
..RoutingContext::default()
blocked_providers,
})
}
@@ -430,7 +400,7 @@ impl DefaultExecutionService {
) -> Result<AccountProbeResult, GatewayError> {
let AccountProbeRequest {
account_id,
provider_instance_id,
provider_kind,
upstream_model,
operation,
} = request;
@@ -440,22 +410,12 @@ impl DefaultExecutionService {
"runtime snapshot is unavailable",
)
})?;
let provider_kind = snapshot
.provider_for_instance(&provider_instance_id)
.cloned()
.ok_or_else(|| {
GatewayError::new(
GatewayErrorKind::Unsupported,
"Provider instance is unavailable",
)
})?;
let public_model =
PublicModelId::new(upstream_model.as_str().to_owned()).map_err(|_| {
GatewayError::new(GatewayErrorKind::Unsupported, "requested model is invalid")
})?;
let routing_context = RoutingContext {
provider_kind: Some(provider_kind),
allowed_instances: Some(BTreeSet::from([provider_instance_id.clone()])),
..RoutingContext::default()
};
let plan = snapshot
@@ -478,7 +438,7 @@ impl DefaultExecutionService {
config_revision: plan.config_revision(),
protocol: "admin_connection_test".to_owned(),
operation: operation.kind(),
endpoint: "/api/admin/accounts/test".to_owned(),
endpoint: "/api/admin/accounts/connection-test".to_owned(),
client_transport: ClientTransport::InternalProbe.as_str().to_owned(),
requested_model: public_model,
input_token_estimate,
@@ -494,7 +454,7 @@ impl DefaultExecutionService {
deadline_at,
};
let mut session = self
.coordinator
.probe_coordinator
.start(
new_request,
operation,
@@ -529,6 +489,52 @@ impl DefaultExecutionService {
}
}
struct TransientExecutionStore;
#[async_trait::async_trait]
impl ExecutionStore for TransientExecutionStore {
async fn create_model_request(&self, _: NewModelRequest) -> Result<(), StoreError> {
Ok(())
}
async fn record_attempt(&self, _: AttemptRecord) -> Result<(), StoreError> {
Ok(())
}
async fn mark_send_state(
&self,
_: &ModelRequestId,
_: UpstreamSendState,
) -> Result<(), StoreError> {
Ok(())
}
async fn mark_downstream_committed(
&self,
_: &ModelRequestId,
_: SystemTime,
_: Option<u16>,
) -> Result<(), StoreError> {
Ok(())
}
async fn record_client_status(&self, _: &ModelRequestId, _: u16) -> Result<(), StoreError> {
Ok(())
}
async fn record_intermediate_failure(&self, _: IntermediateFailure) -> Result<(), StoreError> {
Ok(())
}
async fn finalize_model_request(&self, _: ModelRequestFinalization) -> Result<(), StoreError> {
Ok(())
}
async fn recover_expired(&self, _: SystemTime) -> Result<RecoveryReport, StoreError> {
Ok(RecoveryReport::default())
}
}
impl ExecutionService for DefaultExecutionService {
fn authenticate(
&self,
@@ -758,15 +764,9 @@ async fn publish_provider_attempt_outcomes(
) {
for outcome in outcomes {
let result = match outcome.error_kind() {
None => {
circuits
.observe_success(outcome.provider_instance_id())
.await
}
None => circuits.observe_success(outcome.provider_kind()).await,
Some(kind) if provider_failure_affects_circuit(kind) => {
circuits
.observe_failure(outcome.provider_instance_id())
.await
circuits.observe_failure(outcome.provider_kind()).await
}
Some(_) => continue,
};
@@ -806,7 +806,7 @@ fn map_routing_error(error: crate::error::RoutingError) -> GatewayError {
match error {
crate::error::RoutingError::NoCapableProvider { .. } => GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no Provider instance can execute this request",
"no provider can execute this request",
),
_ => GatewayError::new(
GatewayErrorKind::Internal,
@@ -827,7 +827,7 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
EngineError::EmptyRoutingPlan | EngineError::ProviderNotRegistered { .. } => {
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no Provider instance is available",
"no provider is available",
)
}
_ => GatewayError::new(GatewayErrorKind::Internal, "request execution failed"),
+15 -52
View File
@@ -31,9 +31,7 @@ use crate::error::{
use crate::event::ProviderEvent;
use crate::operation::OperationKind;
use crate::policy::ClientApiKeyId;
use crate::routing::{
ConfigRevision, ProviderInstanceId, ProviderKind, PublicModelId, UpstreamModelId,
};
use crate::routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId};
/// `model_requests.id`。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -105,7 +103,6 @@ impl ExecutionOutcome {
pub enum AttemptTrigger {
Initial,
AccountRetry,
InstanceFallback,
}
impl AttemptTrigger {
@@ -114,39 +111,30 @@ impl AttemptTrigger {
match self {
Self::Initial => "initial",
Self::AccountRetry => "account_retry",
Self::InstanceFallback => "instance_fallback",
}
}
}
/// 一次实际上游调用对 Provider instance 健康度产生的事实。
/// 一次实际上游调用对 Provider 健康度产生的事实。
///
/// 该事实只描述调用结果,不在 Core 中定义 circuit 策略或持久化方式。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderAttemptOutcome {
/// 上游流自然完成且通过 canonical event 序列校验。
Succeeded {
provider_instance_id: ProviderInstanceId,
},
Succeeded { provider_kind: ProviderKind },
/// 上游打开或流式阶段返回了稳定 Provider 错误。
Failed {
provider_instance_id: ProviderInstanceId,
provider_kind: ProviderKind,
error_kind: ProviderErrorKind,
},
}
impl ProviderAttemptOutcome {
/// 返回本次调用实际归属的 Provider instance。
/// 返回本次调用实际归属的 Provider。
#[must_use]
pub const fn provider_instance_id(&self) -> &ProviderInstanceId {
pub const fn provider_kind(&self) -> &ProviderKind {
match self {
Self::Succeeded {
provider_instance_id,
}
| Self::Failed {
provider_instance_id,
..
} => provider_instance_id,
Self::Succeeded { provider_kind } | Self::Failed { provider_kind, .. } => provider_kind,
}
}
@@ -290,46 +278,28 @@ impl AccountAttemptContext {
/// 请求中 Provider 账号绑定状态的唯一归属。
///
/// `turn_state` 等 opaque 状态只能发送给创建它的 Provider、instance 与账号;
/// `turn_state` 等 opaque 状态只能发送给创建它的 Provider 与账号;
/// Core 在首次真实选号后冻结该事实,Provider 据此决定是否清理跨账号状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountStateOwner {
provider: ProviderKind,
instance: ProviderInstanceId,
account: ProviderAccountId,
}
impl ProviderAccountStateOwner {
#[must_use]
pub const fn new(
provider: ProviderKind,
instance: ProviderInstanceId,
account: ProviderAccountId,
) -> Self {
Self {
provider,
instance,
account,
}
pub const fn new(provider: ProviderKind, account: ProviderAccountId) -> Self {
Self { provider, account }
}
#[must_use]
pub fn from_continuation(pin: &NativeContinuationPin) -> Self {
Self::new(
pin.provider().clone(),
pin.instance().clone(),
pin.account().clone(),
)
Self::new(pin.provider().clone(), pin.account().clone())
}
#[must_use]
pub fn matches(
&self,
provider: &ProviderKind,
instance: &ProviderInstanceId,
account: &ProviderAccountId,
) -> bool {
self.provider == *provider && self.instance == *instance && self.account == *account
pub fn matches(&self, provider: &ProviderKind, account: &ProviderAccountId) -> bool {
self.provider == *provider && self.account == *account
}
#[must_use]
@@ -337,11 +307,6 @@ impl ProviderAccountStateOwner {
&self.provider
}
#[must_use]
pub const fn instance(&self) -> &ProviderInstanceId {
&self.instance
}
#[must_use]
pub const fn account(&self) -> &ProviderAccountId {
&self.account
@@ -534,7 +499,6 @@ pub struct AttemptRecord {
pub request_id: ModelRequestId,
pub attempt_count: NonZeroU32,
pub trigger: AttemptTrigger,
pub provider_instance_id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub provider_account_id: Option<ProviderAccountId>,
pub provider_account_ref: Option<ProviderAccountId>,
@@ -543,13 +507,12 @@ pub struct AttemptRecord {
pub http_version: Option<String>,
}
/// 需要解释换号或 Provider instance 切换的中间失败。
/// 需要解释换号的中间失败。
#[derive(Debug)]
pub struct IntermediateFailure {
pub request_id: ModelRequestId,
pub attempt_index: NonZeroU32,
pub trigger: AttemptTrigger,
pub instance_id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub account_id: Option<ProviderAccountId>,
pub upstream_model_id: UpstreamModelId,
@@ -695,7 +658,7 @@ pub enum EngineError {
Store(#[from] StoreError),
#[error("provider `{provider}` is not registered")]
ProviderNotRegistered { provider: String },
#[error("provider metadata did not match the selected Provider instance and account")]
#[error("provider metadata did not match the selected Provider and account")]
ProviderMetadataMismatch,
#[error("native continuation pin did not match the selected account")]
ContinuationPinMismatch,
@@ -3,13 +3,13 @@
use futures::future::BoxFuture;
use crate::error::GatewayError;
use crate::routing::{ProviderInstanceId, UpstreamModelId};
use crate::routing::{ProviderKind, UpstreamModelId};
use crate::{engine::credential::ProviderAccountId, operation::Operation};
#[derive(Debug, Clone, PartialEq)]
pub struct AccountProbeRequest {
pub account_id: ProviderAccountId,
pub provider_instance_id: ProviderInstanceId,
pub provider_kind: ProviderKind,
pub upstream_model: UpstreamModelId,
pub operation: Operation,
}
@@ -17,10 +17,7 @@ use crate::error::{
};
use crate::event::{EventSequenceValidator, ProviderEvent};
use crate::operation::Operation;
use crate::routing::{
ModelCapabilities, ProviderCandidate, ProviderInstance, ProviderInstanceId, ProviderKind,
UpstreamModelId,
};
use crate::routing::{ModelCapabilities, ProviderCandidate, ProviderKind, UpstreamModelId};
/// Box 只出现在 Provider Registry 的统一 event envelope 边界。
pub type EventStream =
@@ -121,7 +118,6 @@ impl UpstreamTransport {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCallMetadata {
provider: ProviderKind,
instance: ProviderInstanceId,
upstream_model: UpstreamModelId,
resource: ProviderResource,
upstream_request_id: Option<SafeUpstreamValue>,
@@ -133,14 +129,12 @@ impl ProviderCallMetadata {
#[must_use]
pub const fn new(
provider: ProviderKind,
instance: ProviderInstanceId,
upstream_model: UpstreamModelId,
resource: ProviderResource,
transport: UpstreamTransport,
) -> Self {
Self {
provider,
instance,
upstream_model,
resource,
upstream_request_id: None,
@@ -161,12 +155,6 @@ impl ProviderCallMetadata {
&self.provider
}
/// 返回 instance。
#[must_use]
pub const fn instance(&self) -> &ProviderInstanceId {
&self.instance
}
/// 返回实际模型;必须与冻结 target 一致。
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
@@ -206,9 +194,7 @@ impl ProviderCallMetadata {
/// 确认 metadata 没有替换请求计划中冻结的 Provider 候选。
#[must_use]
pub fn confirms(&self, candidate: &ProviderCandidate) -> bool {
candidate.provider() == &self.provider
&& candidate.instance() == &self.instance
&& candidate.upstream_model() == &self.upstream_model
candidate.provider() == &self.provider && candidate.upstream_model() == &self.upstream_model
}
}
@@ -386,7 +372,6 @@ impl fmt::Debug for ProviderRequest {
.debug_struct("ProviderRequest")
.field("operation", &self.operation)
.field("provider", self.candidate.provider())
.field("instance", self.candidate.instance())
.field("upstream_model", self.candidate.upstream_model())
.finish()
}
@@ -410,14 +395,13 @@ pub trait Provider: Send + Sync {
ProviderRequestObservation::default()
}
/// 查询 instance 的实时模型目录,并由 Provider 自己编译能力事实。
/// 查询当前 Provider 的实时模型目录,并由 Provider 自己编译能力事实。
///
/// # Errors
///
/// 目录 transport、认证或 Provider 协议失败时返回稳定错误。
async fn query_model_capabilities(
&self,
instance: &ProviderInstance,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError>;
/// 选择一个未被排除的资源并返回 cold [`ProviderStream`]。
@@ -525,6 +509,11 @@ impl ProviderRegistry {
self.providers.get(provider)
}
/// 返回全部已注册 Provider。
pub fn provider_kinds(&self) -> impl Iterator<Item = &ProviderKind> {
self.providers.keys()
}
#[must_use]
pub fn request_observation(
&self,
@@ -547,22 +536,22 @@ impl ProviderRegistry {
.collect()
}
/// 通过编译期 Provider adapter 查询并编译 instance 的实时能力目录。
/// 通过编译期 Provider adapter 查询并编译实时能力目录。
///
/// # Errors
///
/// Provider 未注册或目录查询失败时返回错误。
pub async fn query_model_capabilities(
&self,
instance: &ProviderInstance,
provider_kind: &ProviderKind,
) -> Result<Vec<ProviderModelCapabilities>, ProviderCatalogError> {
let provider = self.providers.get(instance.provider()).ok_or_else(|| {
let provider = self.providers.get(provider_kind).ok_or_else(|| {
ProviderCatalogError::NotRegistered {
provider: instance.provider().as_str().to_owned(),
provider: provider_kind.as_str().to_owned(),
}
})?;
provider
.query_model_capabilities(instance)
.query_model_capabilities()
.await
.map_err(ProviderCatalogError::Query)
}
+2 -2
View File
@@ -87,8 +87,8 @@ pub enum RoutingError {
/// 实体 ID。
id: String,
},
/// 固定平台内没有可执行本次请求的 Provider instance。
#[error("no provider instance can execute model `{model}`")]
/// 固定平台内没有可执行本次请求的 Provider。
#[error("no provider can execute model `{model}`")]
NoCapableProvider {
/// 客户端提交的模型名称。
model: String,
@@ -12,7 +12,7 @@ use crate::engine::credential::{
AccountAvailability, AccountRuntimeSignals, CredentialRevision, OpaqueProviderData,
ProviderAccountId, ProviderAccountStore,
};
use crate::routing::{ProviderInstanceId, ProviderKind};
use crate::routing::ProviderKind;
const MAX_PENDING_FLOW_TTL: Duration = Duration::from_secs(30 * 60);
@@ -44,7 +44,7 @@ impl ProviderStoreError {
}
}
/// 一次 Provider instance 的完整可重建调度状态。
/// 一个 Provider 的完整可重建调度状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSchedulingState {
signals: BTreeMap<ProviderAccountId, AccountRuntimeSignals>,
@@ -85,7 +85,7 @@ impl ProviderSchedulingState {
/// 请求级账号 lease 的全部中立事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSchedulingLeaseRequest {
provider_instance_id: ProviderInstanceId,
provider_kind: ProviderKind,
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
max_concurrent: NonZeroU32,
@@ -96,7 +96,7 @@ pub struct ProviderSchedulingLeaseRequest {
impl ProviderSchedulingLeaseRequest {
#[must_use]
pub const fn new(
provider_instance_id: ProviderInstanceId,
provider_kind: ProviderKind,
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
max_concurrent: NonZeroU32,
@@ -104,7 +104,7 @@ impl ProviderSchedulingLeaseRequest {
deadline: SystemTime,
) -> Self {
Self {
provider_instance_id,
provider_kind,
account_id,
credential_revision,
max_concurrent,
@@ -114,8 +114,8 @@ impl ProviderSchedulingLeaseRequest {
}
#[must_use]
pub const fn provider_instance_id(&self) -> &ProviderInstanceId {
&self.provider_instance_id
pub const fn provider_kind(&self) -> &ProviderKind {
&self.provider_kind
}
#[must_use]
@@ -177,7 +177,7 @@ pub enum ProviderLeaseRequest {
pub trait ProviderLeasePort: Send + Sync {
fn load_state<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
accounts: &'a [ProviderAccountId],
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>>;
@@ -187,7 +187,7 @@ pub trait ProviderLeasePort: Send + Sync {
) -> BoxFuture<'_, Result<ProviderLeaseAcquisition, ProviderStoreError>>;
}
/// 所有 Provider、所有实例共享的 OAuth refresh 并发容量。
/// 所有 Provider 共享的 OAuth refresh 并发容量。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderRefreshCapacityRequest {
max_concurrent: NonZeroU32,
@@ -286,96 +286,6 @@ pub trait ProviderCatalogCachePort: Send + Sync {
) -> BoxFuture<'a, Result<Option<OpaqueProviderData>, ProviderStoreError>>;
}
/// Provider worker 读取的中立 instance 事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstanceConfig {
id: ProviderInstanceId,
provider_kind: ProviderKind,
base_url: String,
enabled: bool,
}
impl ProviderInstanceConfig {
#[must_use]
pub const fn new(
id: ProviderInstanceId,
provider_kind: ProviderKind,
base_url: String,
enabled: bool,
) -> Self {
Self {
id,
provider_kind,
base_url,
enabled,
}
}
#[must_use]
pub const fn id(&self) -> &ProviderInstanceId {
&self.id
}
#[must_use]
pub const fn provider_kind(&self) -> &ProviderKind {
&self.provider_kind
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
#[must_use]
pub const fn enabled(&self) -> bool {
self.enabled
}
}
pub trait ProviderInstanceCatalogPort: Send + Sync {
fn list_instances<'a>(
&'a self,
provider_kind: &'a ProviderKind,
include_disabled: bool,
) -> BoxFuture<'a, Result<Vec<ProviderInstanceConfig>, ProviderStoreError>>;
}
/// Provider 的两种目录能力:instance 事实与 provider-owned model cache。
#[derive(Clone)]
pub struct ProviderCatalogPorts {
instances: Arc<dyn ProviderInstanceCatalogPort>,
model_cache: Arc<dyn ProviderCatalogCachePort>,
}
impl ProviderCatalogPorts {
#[must_use]
pub fn new(
instances: Arc<dyn ProviderInstanceCatalogPort>,
model_cache: Arc<dyn ProviderCatalogCachePort>,
) -> Self {
Self {
instances,
model_cache,
}
}
#[must_use]
pub fn instances(&self) -> Arc<dyn ProviderInstanceCatalogPort> {
Arc::clone(&self.instances)
}
#[must_use]
pub fn model_cache(&self) -> Arc<dyn ProviderCatalogCachePort> {
Arc::clone(&self.model_cache)
}
}
impl fmt::Debug for ProviderCatalogPorts {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ProviderCatalogPorts([CAPABILITIES])")
}
}
/// Redis 中可重建的账号状态投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCredentialState {
@@ -745,7 +655,7 @@ pub trait OAuthPendingFlowPort: Send + Sync {
pub struct ProviderStorePorts {
accounts: Arc<dyn ProviderAccountStore>,
leases: Arc<dyn ProviderLeasePort>,
catalog: ProviderCatalogPorts,
catalog_cache: Arc<dyn ProviderCatalogCachePort>,
credential_state: Arc<dyn ProviderCredentialStatePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
@@ -757,7 +667,7 @@ impl ProviderStorePorts {
pub fn new(
accounts: Arc<dyn ProviderAccountStore>,
leases: Arc<dyn ProviderLeasePort>,
catalog: ProviderCatalogPorts,
catalog_cache: Arc<dyn ProviderCatalogCachePort>,
credential_state: Arc<dyn ProviderCredentialStatePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
@@ -766,7 +676,7 @@ impl ProviderStorePorts {
Self {
accounts,
leases,
catalog,
catalog_cache,
credential_state,
cooldowns,
runtime_policy,
@@ -784,14 +694,9 @@ impl ProviderStorePorts {
Arc::clone(&self.leases)
}
#[must_use]
pub fn instances(&self) -> Arc<dyn ProviderInstanceCatalogPort> {
self.catalog.instances()
}
#[must_use]
pub fn catalog_cache(&self) -> Arc<dyn ProviderCatalogCachePort> {
self.catalog.model_cache()
Arc::clone(&self.catalog_cache)
}
#[must_use]
+16 -137
View File
@@ -1,4 +1,4 @@
//! Provider 平台、实例、模型目录、精确模型映射与请求级候选计划。
//! Provider、模型目录、精确模型映射与请求级候选计划。
pub mod snapshot;
@@ -15,30 +15,6 @@ use crate::operation::{CapabilityRequirements, Feature, OperationKind};
const MAX_REQUEST_ATTEMPTS: u32 = 32;
/// 一个具体 Provider endpoint 的 ID。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProviderInstanceId(String);
impl ProviderInstanceId {
/// 校验并创建 Provider instance ID。
pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
let value = value.into();
validate_text(&value, 128, false, Some("inst_"))?;
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ProviderInstanceId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
/// 编译进二进制的 Provider adapter slug。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProviderKind(String);
@@ -214,151 +190,59 @@ impl ModelCapabilities {
}
}
/// Provider instance 的可重建运行健康状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InstanceHealth {
Healthy,
Degraded,
Unavailable,
CircuitOpen,
Saturated,
}
impl InstanceHealth {
const fn is_routable(self, allow_degraded: bool) -> bool {
matches!(self, Self::Healthy) || (allow_degraded && matches!(self, Self::Degraded))
}
}
/// `provider_instances` 与实时健康状态组成的只读快照。
/// 一个 Provider 实时发现的上游模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstance {
id: ProviderInstanceId,
pub struct ProviderModel {
provider: ProviderKind,
base_url: String,
enabled: bool,
health: InstanceHealth,
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
}
impl ProviderInstance {
impl ProviderModel {
#[must_use]
pub const fn new(
id: ProviderInstanceId,
provider: ProviderKind,
base_url: String,
enabled: bool,
health: InstanceHealth,
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
) -> Self {
Self {
id,
provider,
base_url,
enabled,
health,
upstream_model,
capabilities,
}
}
#[must_use]
pub const fn id(&self) -> &ProviderInstanceId {
&self.id
}
#[must_use]
pub const fn provider(&self) -> &ProviderKind {
&self.provider
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
#[must_use]
pub const fn enabled(&self) -> bool {
self.enabled
}
#[must_use]
pub const fn health(&self) -> InstanceHealth {
self.health
}
}
/// 一个 instance 实时发现的上游模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModel {
instance: ProviderInstanceId,
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
}
impl ProviderModel {
#[must_use]
pub const fn new(
instance: ProviderInstanceId,
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
) -> Self {
Self {
instance,
upstream_model,
capabilities,
}
}
#[must_use]
pub const fn instance(&self) -> &ProviderInstanceId {
&self.instance
}
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
}
}
/// Provider circuit 的 request-scoped observation fence。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderInstanceObservationToken {
pub epoch: u64,
pub fencing_token: u64,
pub probe_fencing_token: Option<u64>,
}
/// 本次请求选择 Provider instance 时使用的动态过滤事实。
/// 本次请求选择 Provider 时使用的动态过滤事实。
#[derive(Debug, Clone, Default)]
pub struct RoutingContext {
/// 已认证 Client API Key 绑定的平台;模型名称不参与平台猜测。
pub provider_kind: Option<ProviderKind>,
pub allowed_instances: Option<BTreeSet<ProviderInstanceId>>,
pub allow_degraded: bool,
pub blocked_instances: BTreeSet<ProviderInstanceId>,
pub provider_observation_tokens: BTreeMap<ProviderInstanceId, ProviderInstanceObservationToken>,
pub blocked_providers: BTreeSet<ProviderKind>,
}
/// 已绑定 Provider、instance 与真实上游模型的请求候选。
/// 已绑定 Provider 与真实上游模型的请求候选。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCandidate {
instance: ProviderInstance,
provider: ProviderKind,
upstream_model: UpstreamModelId,
emulated_features: BTreeSet<Feature>,
observation_token: Option<ProviderInstanceObservationToken>,
}
impl ProviderCandidate {
#[must_use]
pub const fn instance_snapshot(&self) -> &ProviderInstance {
&self.instance
}
#[must_use]
pub const fn instance(&self) -> &ProviderInstanceId {
self.instance.id()
}
#[must_use]
pub const fn provider(&self) -> &ProviderKind {
self.instance.provider()
&self.provider
}
#[must_use]
@@ -370,14 +254,9 @@ impl ProviderCandidate {
pub const fn emulated_features(&self) -> &BTreeSet<Feature> {
&self.emulated_features
}
#[must_use]
pub const fn observation_token(&self) -> Option<ProviderInstanceObservationToken> {
self.observation_token
}
}
/// 一次请求冻结的平台内 Provider instance 尝试顺序。
/// 一次请求冻结的 Provider 尝试顺序。
#[derive(Debug, Clone)]
pub struct RoutingPlan {
config_revision: ConfigRevision,
@@ -24,9 +24,8 @@ use crate::task::{
};
use super::{
ConfigRevision, InstanceHealth, ModelCapabilities, ProviderCandidate, ProviderInstance,
ProviderInstanceId, ProviderKind, ProviderModel, PublicModelId, RoutingContext, RoutingPlan,
UpstreamModelId,
ConfigRevision, ModelCapabilities, ProviderCandidate, ProviderKind, ProviderModel,
PublicModelId, RoutingContext, RoutingPlan, UpstreamModelId,
};
const RECONCILIATION_INTERVAL: Duration = Duration::from_secs(5);
@@ -62,32 +61,6 @@ impl SnapshotSettingsFacts {
}
}
/// Store 读取到的一个 Provider instance 配置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotProviderInstanceFacts {
id: String,
provider_kind: String,
base_url: String,
enabled: bool,
}
impl SnapshotProviderInstanceFacts {
#[must_use]
pub fn new(
id: impl Into<String>,
provider_kind: impl Into<String>,
base_url: impl Into<String>,
enabled: bool,
) -> Self {
Self {
id: id.into(),
provider_kind: provider_kind.into(),
base_url: base_url.into(),
enabled,
}
}
}
/// Store 读取到的一个启用 Client API Key 策略事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotClientPolicyFacts {
@@ -120,7 +93,6 @@ pub struct SnapshotFacts {
config_revision: ConfigRevision,
observed_current_revision: ConfigRevision,
settings: SnapshotSettingsFacts,
provider_instances: Vec<SnapshotProviderInstanceFacts>,
client_policies: Vec<SnapshotClientPolicyFacts>,
}
@@ -130,14 +102,12 @@ impl SnapshotFacts {
config_revision: ConfigRevision,
observed_current_revision: ConfigRevision,
settings: SnapshotSettingsFacts,
provider_instances: Vec<SnapshotProviderInstanceFacts>,
client_policies: Vec<SnapshotClientPolicyFacts>,
) -> Self {
Self {
config_revision,
observed_current_revision,
settings,
provider_instances,
client_policies,
}
}
@@ -226,7 +196,7 @@ impl RuntimeSnapshotCompiler {
Self { store, providers }
}
/// 读取一个 revision,并为 instance 查询实时模型目录。
/// 读取一个 revision,并为已注册 Provider 查询实时模型目录。
pub async fn compile(&self) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
for _ in 0..MAXIMUM_CATALOG_STABILITY_ATTEMPTS {
let catalog_generations = self.providers.catalog_generations();
@@ -252,32 +222,17 @@ async fn compile_runtime_snapshot(
facts: SnapshotFacts,
providers: &ProviderRegistry,
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
let mut instances_by_id = BTreeMap::new();
for record in facts.provider_instances {
let id = ProviderInstanceId::new(record.id)
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?;
let instance = ProviderInstance::new(
id.clone(),
ProviderKind::new(record.provider_kind)
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?,
record.base_url,
record.enabled,
InstanceHealth::Healthy,
);
if instances_by_id.insert(id, instance).is_some() {
return Err(RuntimeSnapshotCompileError::InvalidData);
}
}
let provider_kinds = providers.provider_kinds().cloned().collect::<Vec<_>>();
// 实时目录只提供公开模型与能力提示;查询失败时保留 instance 透传语义。
// 实时目录只提供公开模型与能力提示;查询失败时保留 Provider 透传语义。
let mut provider_models = Vec::new();
for instance in instances_by_id.values() {
let Ok(models) = providers.query_model_capabilities(instance).await else {
for provider in &provider_kinds {
let Ok(models) = providers.query_model_capabilities(provider).await else {
continue;
};
provider_models.extend(models.into_iter().map(|model| {
ProviderModel::new(
instance.id().clone(),
provider.clone(),
model.upstream_model().clone(),
model.capabilities().clone(),
)
@@ -326,7 +281,7 @@ async fn compile_runtime_snapshot(
RuntimeSnapshot::new(
facts.config_revision,
selection_policy,
instances_by_id.into_values().collect(),
provider_kinds,
provider_models,
client_policies,
)
@@ -339,44 +294,42 @@ async fn compile_runtime_snapshot(
pub struct RuntimeSnapshot {
revision: ConfigRevision,
account_selection_policy: AccountSelectionPolicy,
instances: Arc<BTreeMap<ProviderInstanceId, ProviderInstance>>,
provider_models:
Arc<BTreeMap<ProviderInstanceId, BTreeMap<UpstreamModelId, ModelCapabilities>>>,
providers: Arc<BTreeSet<ProviderKind>>,
provider_models: Arc<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>>,
provider_model_mappings: Arc<BTreeMap<ProviderKind, BTreeMap<String, String>>>,
provider_catalog_generations: Arc<BTreeMap<ProviderKind, ProviderCatalogGeneration>>,
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
}
impl RuntimeSnapshot {
/// 校验 instance、实时模型目录和 Client API Key,并构建快照。
/// 校验 Provider、实时模型目录和 Client API Key,并构建快照。
pub fn new(
revision: ConfigRevision,
account_selection_policy: AccountSelectionPolicy,
instances: Vec<ProviderInstance>,
providers: Vec<ProviderKind>,
provider_models: Vec<ProviderModel>,
client_policies: Vec<ClientPolicy>,
) -> Result<Self, RoutingError> {
let mut instance_map = BTreeMap::new();
for instance in instances {
let id = instance.id().clone();
if instance_map.insert(id.clone(), instance).is_some() {
let mut provider_set = BTreeSet::new();
for provider in providers {
if !provider_set.insert(provider.clone()) {
return Err(RoutingError::DuplicateEntity {
entity: "provider instance",
id: id.to_string(),
entity: "provider",
id: provider.to_string(),
});
}
}
let mut model_map =
BTreeMap::<ProviderInstanceId, BTreeMap<UpstreamModelId, ModelCapabilities>>::new();
BTreeMap::<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>::new();
for model in provider_models {
if !instance_map.contains_key(model.instance()) {
if !provider_set.contains(model.provider()) {
return Err(RoutingError::NotFound {
entity: "provider instance",
id: model.instance().to_string(),
entity: "provider",
id: model.provider().to_string(),
});
}
let models = model_map.entry(model.instance).or_default();
let models = model_map.entry(model.provider).or_default();
let upstream_model = model.upstream_model;
if models
.insert(upstream_model.clone(), model.capabilities)
@@ -404,7 +357,7 @@ impl RuntimeSnapshot {
Ok(Self {
revision,
account_selection_policy,
instances: Arc::new(instance_map),
providers: Arc::new(provider_set),
provider_models: Arc::new(model_map),
provider_model_mappings: Arc::new(BTreeMap::new()),
provider_catalog_generations: Arc::new(BTreeMap::new()),
@@ -446,13 +399,7 @@ impl RuntimeSnapshot {
#[must_use]
pub fn public_models_for_provider(&self, provider: &ProviderKind) -> Vec<PublicModelId> {
let mut models = BTreeSet::new();
for (instance_id, discovered) in self.provider_models.iter() {
let Some(instance) = self.instances.get(instance_id) else {
continue;
};
if !instance.enabled || instance.provider() != provider {
continue;
}
if let Some(discovered) = self.provider_models.get(provider) {
models.extend(
discovered
.keys()
@@ -471,12 +418,7 @@ impl RuntimeSnapshot {
#[must_use]
pub fn public_models(&self) -> Vec<PublicModelId> {
let providers = self
.instances
.values()
.map(|instance| instance.provider().clone())
.collect::<BTreeSet<_>>();
providers
self.providers
.iter()
.flat_map(|provider| self.public_models_for_provider(provider))
.collect::<BTreeSet<_>>()
@@ -484,36 +426,14 @@ impl RuntimeSnapshot {
.collect()
}
/// 透明代理不以目录白名单拒绝模型;只要求该平台至少有一个启用 instance。
/// 透明代理不以目录白名单拒绝模型;只要求该 Provider 已注册。
#[must_use]
pub fn contains_public_model_for_provider(
&self,
_public_model: &PublicModelId,
provider: &ProviderKind,
) -> bool {
self.instances
.values()
.any(|instance| instance.enabled && instance.provider() == provider)
}
#[must_use]
pub fn instance_ids_for_provider(
&self,
provider: &ProviderKind,
) -> BTreeSet<ProviderInstanceId> {
self.instances
.iter()
.filter_map(|(id, instance)| {
(instance.enabled && instance.provider() == provider).then_some(id.clone())
})
.collect()
}
#[must_use]
pub fn provider_for_instance(&self, instance_id: &ProviderInstanceId) -> Option<&ProviderKind> {
self.instances
.get(instance_id)
.map(ProviderInstance::provider)
self.providers.contains(provider)
}
#[must_use]
@@ -535,22 +455,24 @@ impl RuntimeSnapshot {
operation: &Operation,
context: &RoutingContext,
) -> Result<RoutingPlan, RoutingError> {
let upstream_model = context.provider_kind.as_ref().map_or_else(
|| public_model.as_str().to_owned(),
|provider| self.mapped_model(provider, public_model.as_str()),
);
let upstream_model =
UpstreamModelId::new(upstream_model).map_err(|_| RoutingError::InvalidIdentifier)?;
let requirements = operation.capability_requirements();
let mut candidates = Vec::new();
for instance in self.instances.values() {
if !self.instance_is_eligible(instance, context) {
for provider in self.providers.iter() {
if context
.provider_kind
.as_ref()
.is_some_and(|expected| expected != provider)
|| context.blocked_providers.contains(provider)
{
continue;
}
let upstream_model =
UpstreamModelId::new(self.mapped_model(provider, public_model.as_str()))
.map_err(|_| RoutingError::InvalidIdentifier)?;
let emulated_features = match self
.provider_models
.get(instance.id())
.get(provider)
.and_then(|models| models.get(&upstream_model))
{
Some(capabilities) => {
@@ -562,13 +484,9 @@ impl RuntimeSnapshot {
None => BTreeSet::new(),
};
candidates.push(ProviderCandidate {
instance: instance.clone(),
upstream_model: upstream_model.clone(),
provider: provider.clone(),
upstream_model,
emulated_features,
observation_token: context
.provider_observation_tokens
.get(instance.id())
.copied(),
});
}
@@ -588,20 +506,6 @@ impl RuntimeSnapshot {
candidates: Arc::from(candidates),
})
}
fn instance_is_eligible(&self, instance: &ProviderInstance, context: &RoutingContext) -> bool {
instance.enabled
&& instance.health.is_routable(context.allow_degraded)
&& context
.provider_kind
.as_ref()
.is_none_or(|provider| instance.provider() == provider)
&& !context.blocked_instances.contains(instance.id())
&& context
.allowed_instances
.as_ref()
.is_none_or(|allowed| allowed.contains(instance.id()))
}
}
/// 请求级冻结失败;此状态必须 fail closed。
@@ -3,14 +3,13 @@ use gateway_core::engine::continuation::{
};
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::error::SafeUpstreamValue;
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use gateway_core::routing::ProviderKind;
fn pin() -> NativeContinuationPin {
NativeContinuationPin::new(
PreviousResponseId::new("response-private").expect("valid response"),
SafeUpstreamValue::new("upstream-private").expect("valid upstream response"),
ProviderKind::new("openai").expect("valid provider"),
ProviderInstanceId::new("inst_openai").expect("valid instance"),
ProviderAccountId::new("acct_codex").expect("valid account"),
)
}
@@ -35,7 +34,6 @@ fn external_binding_debug_should_redact_previous_response_id() {
fn native_pin_should_reject_different_account() {
assert!(!pin().matches(
&ProviderKind::new("openai").expect("valid provider"),
&ProviderInstanceId::new("inst_openai").expect("valid instance"),
&ProviderAccountId::new("acct_other").expect("valid account"),
));
}
@@ -38,9 +38,8 @@ use gateway_core::operation::{
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ConfigRevision, InstanceHealth, ModelCapabilities, ProviderInstance, ProviderInstanceId,
ProviderKind, ProviderModel, PublicModelId, RoutingContext, RoutingPlan, RuntimeSnapshot,
UpstreamModelId,
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
RoutingPlan, RuntimeSnapshot, UpstreamModelId,
};
use serde_json::{Map, Value};
@@ -245,7 +244,6 @@ impl Provider for ScriptedProvider {
async fn query_model_capabilities(
&self,
_instance: &ProviderInstance,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
@@ -268,7 +266,6 @@ impl Provider for ScriptedProvider {
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.instance().clone(),
candidate.upstream_model().clone(),
ProviderResource::Account {
id: ProviderAccountId::new(account_id).expect("account id"),
@@ -293,7 +290,6 @@ impl Provider for ScriptedProvider {
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.instance().clone(),
candidate.upstream_model().clone(),
ProviderResource::Account {
id: ProviderAccountId::new(account_id).expect("account id"),
@@ -375,29 +371,11 @@ fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, Pr
]
}
fn plan(operation: &Operation, instance_count: u32) -> RoutingPlan {
fn plan(operation: &Operation) -> RoutingPlan {
let provider = ProviderKind::new("openai").expect("provider");
let public_model = PublicModelId::new("gpt-5").expect("public model");
let capabilities =
ModelCapabilities::new(BTreeSet::from([operation.kind()]), 128_000, Some(32_000));
let mut instances = Vec::new();
let mut provider_models = Vec::new();
for index in 1..=instance_count {
let instance_id =
ProviderInstanceId::new(format!("inst_openai_{index}")).expect("instance id");
instances.push(ProviderInstance::new(
instance_id.clone(),
provider.clone(),
format!("https://openai-{index}.example"),
true,
InstanceHealth::Healthy,
));
provider_models.push(ProviderModel::new(
instance_id,
UpstreamModelId::new("gpt-5").expect("upstream model"),
capabilities.clone(),
));
}
RuntimeSnapshot::new(
ConfigRevision::new(1).expect("config revision"),
AccountSelectionPolicy::new(
@@ -405,8 +383,12 @@ fn plan(operation: &Operation, instance_count: u32) -> RoutingPlan {
NonZeroU32::new(2).expect("account concurrency"),
Duration::from_millis(50),
),
instances,
provider_models,
vec![provider.clone()],
vec![ProviderModel::new(
provider.clone(),
UpstreamModelId::new("gpt-5").expect("upstream model"),
capabilities,
)],
Vec::new(),
)
.expect("snapshot")
@@ -469,7 +451,7 @@ fn terminal_non_idempotent_failure(
continuation: Option<ContinuationBinding>,
) -> (Arc<FakeStore>, Arc<ScriptedProvider>) {
let operation = operation(RetrySafety::NonIdempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -500,7 +482,7 @@ fn terminal_non_idempotent_failure(
#[test]
fn success_updates_one_model_request_and_persists_usage() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Stream {
account_id: "acct_one",
items: complete_stream(Some(12)),
@@ -535,7 +517,7 @@ fn success_updates_one_model_request_and_persists_usage() {
assert_eq!(
session.provider_attempt_outcomes(),
&[ProviderAttemptOutcome::Succeeded {
provider_instance_id: ProviderInstanceId::new("inst_openai_1").expect("instance"),
provider_kind: ProviderKind::new("openai").expect("provider"),
}]
);
let state = store.state.lock().expect("store lock");
@@ -561,7 +543,7 @@ fn success_updates_one_model_request_and_persists_usage() {
fn finalized_image_request(image_output_tokens: Option<u64>) -> FinalState {
let operation = image_generate_operation();
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Stream {
account_id: "acct_image",
items: image_stream(image_output_tokens),
@@ -605,7 +587,7 @@ fn image_request_without_output_should_persist_failure() {
#[test]
fn failed_image_request_should_persist_failure() {
let operation = image_generate_operation();
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Stream {
account_id: "acct_image",
items: vec![Err(ProviderError::new(
@@ -634,7 +616,7 @@ fn failed_image_request_should_persist_failure() {
#[test]
fn response_observation_is_persisted_but_never_delivered() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let observation = ProviderResponseObservation::new(
UpstreamTransport::new("http_sse").expect("actual transport"),
)
@@ -691,7 +673,7 @@ fn response_observation_is_persisted_but_never_delivered() {
#[test]
fn unknown_wire_event_before_response_identity_is_discarded_with_retried_attempt() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let unknown = ProtocolWireEvent::json(
"openai",
Some("response.future_event".to_owned()),
@@ -737,7 +719,7 @@ fn unknown_wire_event_before_response_identity_is_discarded_with_retried_attempt
#[test]
fn discarded_attempt_observation_does_not_leak_into_retry_result() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let first_observation = ProviderResponseObservation::new(
UpstreamTransport::new("websocket").expect("first transport"),
)
@@ -818,7 +800,7 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
#[test]
fn websocket_success_keeps_client_http_status_absent() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Stream {
account_id: "acct_one",
items: complete_stream(None),
@@ -844,7 +826,7 @@ fn websocket_success_keeps_client_http_status_absent() {
#[test]
fn authenticated_native_continuation_reaches_every_attempt_context() {
let operation = generate_operation();
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_one",
items: complete_stream(None),
@@ -853,7 +835,6 @@ fn authenticated_native_continuation_reaches_every_attempt_context() {
PreviousResponseId::new("previous-secret-id").expect("previous response"),
SafeUpstreamValue::new("provider-native-id").expect("upstream response"),
ProviderKind::new("openai").expect("provider"),
ProviderInstanceId::new("inst_openai_1").expect("instance"),
ProviderAccountId::new("acct_one").expect("account"),
);
@@ -895,7 +876,6 @@ fn authenticated_native_continuation_reaches_every_attempt_context() {
.expect("continuation account-state owner");
assert!(owner.matches(
&ProviderKind::new("openai").expect("provider"),
&ProviderInstanceId::new("inst_openai_1").expect("instance"),
&ProviderAccountId::new("acct_one").expect("account"),
));
}
@@ -910,7 +890,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
let operation = Operation::Generate(generate.with_provider_session_state(
ProviderSessionState::new("openai", payload).expect("provider session state"),
));
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_one",
@@ -943,7 +923,6 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
PreviousResponseId::new("previous-secret-id").expect("previous response"),
SafeUpstreamValue::new("provider-native-id").expect("upstream response"),
ProviderKind::new("openai").expect("provider"),
ProviderInstanceId::new("inst_openai_1").expect("instance"),
ProviderAccountId::new("acct_one").expect("account"),
);
@@ -983,7 +962,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
#[test]
fn required_account_reaches_provider_and_matching_metadata_succeeds() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 2);
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_required",
items: complete_stream(None),
@@ -1009,7 +988,7 @@ fn required_account_reaches_provider_and_matching_metadata_succeeds() {
#[test]
fn provider_metadata_for_another_account_fails_closed() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 2);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_wrong",
@@ -1043,93 +1022,10 @@ fn provider_metadata_for_another_account_fails_closed() {
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
}
#[test]
fn required_account_disables_instance_fallback_before_stream() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 2);
let (coordinator, store, provider) = coordinator(vec![
Script::Error(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
)),
Script::Stream {
account_id: "acct_required",
items: complete_stream(None),
},
]);
let required = ProviderAccountId::new("acct_required").expect("account id");
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
Some(required.clone()),
None,
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("required account must not switch targets");
assert!(matches!(
error,
gateway_core::engine::EngineError::Provider(_)
));
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 1);
assert_eq!(contexts[0].required_account(), Some(&required));
assert_eq!(provider.scripts.lock().expect("scripts lock").len(), 1);
let state = store.state.lock().expect("store lock");
assert!(state.attempts.is_empty());
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
}
#[test]
fn local_selection_failure_falls_back_without_instance_failure_observation() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 2);
let (coordinator, store, provider) = coordinator(vec![
Script::Error(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
)),
Script::Stream {
account_id: "acct_second",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("fallback response");
block_on(session.commit_downstream(Some(200))).expect("commit response");
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
assert_eq!(
session.provider_attempt_outcomes(),
&[ProviderAttemptOutcome::Succeeded {
provider_instance_id: ProviderInstanceId::new("inst_openai_2")
.expect("second instance"),
}]
);
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(
state.attempts[0].provider_instance_id,
ProviderInstanceId::new("inst_openai_2").expect("second instance")
);
}
#[test]
fn required_account_disables_account_retry_after_stream_creation() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_required",
@@ -1162,7 +1058,7 @@ fn required_account_disables_account_retry_after_stream_creation() {
assert_eq!(
session.provider_attempt_outcomes(),
&[ProviderAttemptOutcome::Failed {
provider_instance_id: ProviderInstanceId::new("inst_openai_1").expect("instance"),
provider_kind: ProviderKind::new("openai").expect("provider"),
error_kind: ProviderErrorKind::Unavailable,
}]
);
@@ -1177,7 +1073,7 @@ fn required_account_disables_account_retry_after_stream_creation() {
#[test]
fn latest_provider_reported_cost_is_persisted_as_known_usd_total() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let events = vec![
Ok(GatewayEvent::Started(ResponseMeta::new(
"native-cost",
@@ -1220,7 +1116,7 @@ fn latest_provider_reported_cost_is_persisted_as_known_usd_total() {
#[test]
fn calculated_cost_is_persisted_when_provider_does_not_report_cost() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let events = vec![
Ok(GatewayEvent::Started(ResponseMeta::new(
"calculated-cost",
@@ -1258,7 +1154,7 @@ fn calculated_cost_is_persisted_when_provider_does_not_report_cost() {
#[test]
fn provider_reported_cost_should_not_be_replaced_by_calculated_cost() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let events = vec![
Ok(GatewayEvent::Started(ResponseMeta::new(
"reported-cost",
@@ -1307,7 +1203,7 @@ fn provider_reported_cost_should_not_be_replaced_by_calculated_cost() {
#[test]
fn discarded_attempt_cost_never_leaks_into_retry_result() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -1352,7 +1248,7 @@ fn discarded_attempt_cost_never_leaks_into_retry_result() {
#[test]
fn pre_commit_failure_excludes_account_and_retries_same_target() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -1394,7 +1290,6 @@ fn pre_commit_failure_excludes_account_and_retries_same_target() {
.account_state_owner()
.is_some_and(|owner| owner.matches(
&ProviderKind::new("openai").expect("provider"),
&ProviderInstanceId::new("inst_openai_1").expect("instance"),
&ProviderAccountId::new("acct_first").expect("account"),
))
);
@@ -1417,7 +1312,7 @@ fn pre_commit_failure_excludes_account_and_retries_same_target() {
#[test]
fn recovered_credential_retries_the_same_account_exactly_once() {
let operation = operation(RetrySafety::NonIdempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -1462,7 +1357,7 @@ fn recovered_credential_retries_the_same_account_exactly_once() {
#[test]
fn non_idempotent_explicit_429_rejection_rotates_account_before_output() {
let operation = operation(RetrySafety::NonIdempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -1542,7 +1437,7 @@ fn explicit_429_with_ambiguous_send_state_is_not_retried() {
#[test]
fn explicit_429_after_structural_event_should_retry_before_commit() {
let operation = operation(RetrySafety::NonIdempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -1589,7 +1484,6 @@ fn native_continuation_explicit_429_is_not_retried() {
PreviousResponseId::new("previous-secret-id").expect("previous response"),
SafeUpstreamValue::new("provider-native-id").expect("upstream response"),
ProviderKind::new("openai").expect("provider"),
ProviderInstanceId::new("inst_openai_1").expect("instance"),
ProviderAccountId::new("acct_first").expect("account"),
);
let (store, provider) = terminal_non_idempotent_failure(
@@ -1632,49 +1526,10 @@ fn external_continuation_explicit_429_is_not_retried() {
assert_eq!(state.intermediate_failures, 0);
}
#[test]
fn non_idempotent_not_sent_failure_can_fallback_target() {
let operation = operation(RetrySafety::NonIdempotent);
let route_plan = plan(&operation, 2);
let (coordinator, store, provider) = coordinator(vec![
Script::Error(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
)),
Script::Stream {
account_id: "acct_second",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("safe instance fallback succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit response");
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 2);
assert!(contexts[0].account_state_owner().is_none());
assert!(contexts[1].account_state_owner().is_none());
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(
state.attempts[0].provider_instance_id,
ProviderInstanceId::new("inst_openai_2").expect("second instance")
);
}
#[test]
fn ambiguous_send_state_stops_retry() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -1720,7 +1575,7 @@ fn ambiguous_send_state_stops_retry() {
#[test]
fn structural_event_before_replay_safe_failure_should_switch_account_before_commit() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -1763,7 +1618,7 @@ fn structural_event_before_replay_safe_failure_should_switch_account_before_comm
assert_eq!(
session.provider_attempt_outcomes(),
&[ProviderAttemptOutcome::Failed {
provider_instance_id: ProviderInstanceId::new("inst_openai_1").expect("instance"),
provider_kind: ProviderKind::new("openai").expect("provider"),
error_kind: ProviderErrorKind::Transport,
}]
);
@@ -1779,7 +1634,7 @@ fn structural_event_before_replay_safe_failure_should_switch_account_before_comm
#[test]
fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items: complete_stream(None),
@@ -1811,9 +1666,9 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
}
#[test]
fn local_unavailability_before_stream_does_not_create_attempt_or_instance_failure() {
fn local_unavailability_before_stream_does_not_create_attempt_or_provider_failure() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, _) = coordinator(vec![Script::Error(
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
.with_upstream_code(SafeUpstreamValue::new("overloaded").expect("provider code"))
@@ -1850,7 +1705,7 @@ fn local_unavailability_before_stream_does_not_create_attempt_or_instance_failur
#[test]
fn expired_deadline_finalizes_without_calling_provider() {
let operation = operation(RetrySafety::Idempotent);
let route_plan = plan(&operation, 1);
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![]);
let error = match block_on(coordinator.start(
@@ -10,12 +10,11 @@ use gateway_core::engine::credential::{
CredentialRevision, OpaqueProviderData, PlaintextCredential, ProviderAccount,
ProviderAccountId, ProviderAccountUpdate, RotationStrategy,
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use gateway_core::routing::ProviderKind;
fn account(id: &str) -> ProviderAccount {
ProviderAccount::new(
ProviderAccountId::new(id).expect("valid account"),
ProviderInstanceId::new("inst_test").expect("valid instance"),
ProviderKind::new("openai").expect("valid provider"),
id.to_owned(),
format!("user-{id}"),
@@ -1,8 +1,47 @@
use gateway_core::engine::execution::provider_failure_affects_circuit;
use gateway_core::error::ProviderErrorKind;
use std::{
collections::BTreeSet,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, SystemTime},
};
use async_trait::async_trait;
use futures::{executor::block_on, future::BoxFuture};
use gateway_core::engine::admission::{
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
ClientAdmissionRequest, ClientAdmissionRestoreResult,
};
use gateway_core::engine::continuation::{
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
};
use gateway_core::engine::credential::{
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
};
use gateway_core::engine::execution::{
DefaultExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
provider_failure_affects_circuit,
};
use gateway_core::engine::probe::{AccountProbe, AccountProbeRequest};
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::engine::{
AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
NewModelRequest, RecoveryReport, UpstreamSendState,
};
use gateway_core::error::{GatewayErrorKind, ProviderErrorKind, StoreError};
use gateway_core::operation::{
ContentPart, GenerateRequest, Message, MessageRole, Operation, OperationKind,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, RuntimeSnapshot,
UpstreamModelId,
};
#[test]
fn only_instance_attributable_failures_should_affect_circuit() {
fn only_provider_attributable_failures_should_affect_circuit() {
assert!(provider_failure_affects_circuit(ProviderErrorKind::Timeout));
assert!(provider_failure_affects_circuit(
ProviderErrorKind::Transport
@@ -14,3 +53,187 @@ fn only_instance_attributable_failures_should_affect_circuit() {
ProviderErrorKind::InvalidRequest
));
}
#[test]
fn account_probe_should_not_write_to_the_persistent_execution_store() {
let store = Arc::new(TrackingExecutionStore::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(probe_snapshot()),
store.clone(),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
);
let error = block_on(service.probe(AccountProbeRequest {
account_id: ProviderAccountId::new("acct_probe").expect("account ID"),
provider_kind: ProviderKind::new("openai").expect("provider kind"),
upstream_model: UpstreamModelId::new("gpt-probe").expect("model ID"),
operation: probe_operation(),
}))
.expect_err("empty Provider registry should stop the probe after it starts");
assert_eq!(error.kind(), GatewayErrorKind::NoAvailableProvider);
assert!(!store.touched.load(Ordering::SeqCst));
}
#[derive(Default)]
struct TrackingExecutionStore {
touched: AtomicBool,
}
impl TrackingExecutionStore {
fn touch(&self) {
self.touched.store(true, Ordering::SeqCst);
}
}
#[async_trait]
impl ExecutionStore for TrackingExecutionStore {
async fn create_model_request(&self, _: NewModelRequest) -> Result<(), StoreError> {
self.touch();
Ok(())
}
async fn record_attempt(&self, _: AttemptRecord) -> Result<(), StoreError> {
self.touch();
Ok(())
}
async fn mark_send_state(
&self,
_: &ModelRequestId,
_: UpstreamSendState,
) -> Result<(), StoreError> {
self.touch();
Ok(())
}
async fn mark_downstream_committed(
&self,
_: &ModelRequestId,
_: SystemTime,
_: Option<u16>,
) -> Result<(), StoreError> {
self.touch();
Ok(())
}
async fn record_client_status(&self, _: &ModelRequestId, _: u16) -> Result<(), StoreError> {
self.touch();
Ok(())
}
async fn record_intermediate_failure(&self, _: IntermediateFailure) -> Result<(), StoreError> {
self.touch();
Ok(())
}
async fn finalize_model_request(&self, _: ModelRequestFinalization) -> Result<(), StoreError> {
self.touch();
Ok(())
}
async fn recover_expired(&self, _: SystemTime) -> Result<RecoveryReport, StoreError> {
self.touch();
Ok(RecoveryReport::default())
}
}
struct UnusedAdmissions;
impl ClientAdmissionPort for UnusedAdmissions {
fn admit(
&self,
_: ClientAdmissionRequest,
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
Box::pin(async { Ok(ClientAdmissionDecision::Granted) })
}
fn release<'a>(
&'a self,
_: &'a ClientApiKeyId,
_: &'a ModelRequestId,
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
Box::pin(async { Ok(true) })
}
fn restore(
&self,
_: ClientAdmissionRecovery,
) -> BoxFuture<'_, Result<ClientAdmissionRestoreResult, ClientAdmissionError>> {
Box::pin(async { Ok(ClientAdmissionRestoreResult::default()) })
}
}
struct UnusedCircuits;
impl ProviderCircuitPort for UnusedCircuits {
fn decision<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
Box::pin(async { Ok(ProviderCircuitDecision::Allow) })
}
fn observe_failure<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { Ok(()) })
}
fn observe_success<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { Ok(()) })
}
}
struct UnusedContinuation;
impl NativeContinuationPort for UnusedContinuation {
fn resolve<'a>(
&'a self,
_: &'a ClientApiKeyId,
_: &'a PreviousResponseId,
) -> BoxFuture<'a, Result<Option<NativeContinuationPin>, NativeContinuationStoreError>> {
Box::pin(async { Ok(None) })
}
}
fn probe_snapshot() -> RuntimeSnapshot {
let provider = ProviderKind::new("openai").expect("provider kind");
let capabilities = ModelCapabilities::new(
BTreeSet::from([OperationKind::Generate]),
128_000,
Some(16_000),
);
RuntimeSnapshot::new(
ConfigRevision::new(1).expect("config revision"),
AccountSelectionPolicy::new(
RotationStrategy::Smart,
std::num::NonZeroU32::new(1).expect("concurrency"),
Duration::from_millis(1),
),
vec![provider.clone()],
vec![ProviderModel::new(
provider,
UpstreamModelId::new("gpt-probe").expect("model ID"),
capabilities,
)],
Vec::new(),
)
.expect("probe snapshot")
}
fn probe_operation() -> Operation {
let message = Message::new(
MessageRole::User,
vec![ContentPart::Text("ping".to_owned())],
)
.expect("probe message");
Operation::Generate(GenerateRequest::new(vec![message]).expect("probe request"))
}
@@ -13,10 +13,7 @@ use gateway_core::engine::provider::{
use gateway_core::engine::{AttemptContext, UpstreamSendState};
use gateway_core::error::{IdentifierError, ProviderError, ProviderErrorKind};
use gateway_core::operation::OperationKind;
use gateway_core::routing::{
InstanceHealth, ModelCapabilities, ProviderInstance, ProviderInstanceId, ProviderKind,
UpstreamModelId,
};
use gateway_core::routing::{ModelCapabilities, ProviderKind, UpstreamModelId};
struct NamedProvider(&'static str);
@@ -32,7 +29,6 @@ impl Provider for NamedProvider {
async fn query_model_capabilities(
&self,
_instance: &ProviderInstance,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(vec![ProviderModelCapabilities::new(
UpstreamModelId::new("live-model").expect("model"),
@@ -78,15 +74,8 @@ fn registry_should_query_provider_compiled_model_capabilities() {
.register(Arc::new(NamedProvider("openai")))
.expect("provider");
let registry = builder.build();
let instance = ProviderInstance::new(
ProviderInstanceId::new("inst_openai").expect("instance"),
ProviderKind::new("openai").expect("provider kind"),
"https://api.example".to_owned(),
true,
InstanceHealth::Healthy,
);
let models = futures::executor::block_on(registry.query_model_capabilities(&instance))
let provider = ProviderKind::new("openai").expect("provider kind");
let models = futures::executor::block_on(registry.query_model_capabilities(&provider))
.expect("live catalog");
assert_eq!(models[0].upstream_model().as_str(), "live-model");
@@ -113,7 +102,6 @@ fn provider_stream_should_release_owned_lease_on_drop() {
let released = Arc::new(AtomicBool::new(false));
let metadata = ProviderCallMetadata::new(
ProviderKind::new("openai").expect("valid provider"),
ProviderInstanceId::new("inst_openai").expect("valid instance"),
UpstreamModelId::new("gpt-5").expect("valid model"),
ProviderResource::Anonymous(ResourceId::none()),
UpstreamTransport::new("http_sse").expect("valid transport"),
@@ -9,8 +9,8 @@ use gateway_core::operation::{
};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::{
ConfigRevision, InstanceHealth, ModelCapabilities, ProviderInstance, ProviderInstanceId,
ProviderKind, ProviderModel, PublicModelId, RoutingContext, RuntimeSnapshot, UpstreamModelId,
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
RuntimeSnapshot, UpstreamModelId,
};
mod snapshot;
@@ -31,19 +31,9 @@ fn capabilities() -> ModelCapabilities {
)
}
fn instance(id: &str, provider: &str) -> ProviderInstance {
ProviderInstance::new(
ProviderInstanceId::new(id).expect("valid instance"),
ProviderKind::new(provider).expect("valid provider"),
format!("https://{provider}.example.invalid"),
true,
InstanceHealth::Healthy,
)
}
fn model(instance: &str, name: &str, capabilities: ModelCapabilities) -> ProviderModel {
fn model(provider: &str, name: &str, capabilities: ModelCapabilities) -> ProviderModel {
ProviderModel::new(
ProviderInstanceId::new(instance).expect("valid instance"),
ProviderKind::new(provider).expect("valid provider"),
UpstreamModelId::new(name).expect("valid model"),
capabilities,
)
@@ -73,12 +63,12 @@ fn snapshot() -> RuntimeSnapshot {
ConfigRevision::new(1).expect("revision"),
scheduling(),
vec![
instance("inst_openai", "openai"),
instance("inst_xai", "xai"),
ProviderKind::new("openai").expect("provider"),
ProviderKind::new("xai").expect("provider"),
],
vec![
model("inst_openai", "gpt-5.5", capabilities()),
model("inst_xai", "grok-4.5", capabilities()),
model("openai", "gpt-5.5", capabilities()),
model("xai", "grok-4.5", capabilities()),
],
Vec::new(),
)
@@ -100,17 +90,12 @@ fn config_revision_should_reject_zero() {
assert!(ConfigRevision::new(0).is_err());
}
#[test]
fn provider_instance_id_should_require_prefix() {
assert!(ProviderInstanceId::new("missing").is_err());
}
#[test]
fn snapshot_should_publish_only_enabled_plaintext_client_policies() {
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
scheduling(),
vec![instance("inst_openai", "openai")],
vec![ProviderKind::new("openai").expect("provider")],
Vec::new(),
vec![
client_policy("key_enabled", "sk_enabled", true),
@@ -126,12 +111,12 @@ fn snapshot_should_publish_only_enabled_plaintext_client_policies() {
}
#[test]
fn snapshot_should_reject_model_for_missing_instance() {
fn snapshot_should_reject_model_for_missing_provider() {
let result = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
scheduling(),
Vec::new(),
vec![model("inst_missing", "gpt-5.5", capabilities())],
vec![model("missing", "gpt-5.5", capabilities())],
Vec::new(),
);
@@ -139,14 +124,14 @@ fn snapshot_should_reject_model_for_missing_instance() {
}
#[test]
fn snapshot_should_reject_duplicate_instance_model() {
fn snapshot_should_reject_duplicate_provider_model() {
let result = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
scheduling(),
vec![instance("inst_openai", "openai")],
vec![ProviderKind::new("openai").expect("provider")],
vec![
model("inst_openai", "gpt-5.5", capabilities()),
model("inst_openai", "gpt-5.5", capabilities()),
model("openai", "gpt-5.5", capabilities()),
model("openai", "gpt-5.5", capabilities()),
],
Vec::new(),
);
@@ -212,7 +197,7 @@ fn unmapped_model_should_pass_through_unchanged() {
}
#[test]
fn blocked_instance_should_be_filtered() {
fn blocked_provider_should_be_filtered() {
let snapshot = snapshot();
let error = snapshot
.plan(
@@ -220,10 +205,7 @@ fn blocked_instance_should_be_filtered() {
&operation(),
&RoutingContext {
provider_kind: Some(ProviderKind::new("openai").expect("provider")),
blocked_instances: BTreeSet::from([
ProviderInstanceId::new("inst_openai").expect("instance")
]),
..RoutingContext::default()
blocked_providers: BTreeSet::from([ProviderKind::new("openai").expect("provider")]),
},
)
.expect_err("blocked platform has no candidate");
@@ -239,9 +221,9 @@ fn known_unsupported_operation_should_not_be_bypassed() {
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
scheduling(),
vec![instance("inst_openai", "openai")],
vec![ProviderKind::new("openai").expect("provider")],
vec![model(
"inst_openai",
"openai",
"gpt-known-unsupported",
ModelCapabilities::new(BTreeSet::new(), 0, None),
)],
@@ -305,7 +287,7 @@ fn public_catalog_should_include_discovered_models_and_aliases() {
}
#[test]
fn unknown_model_should_be_accepted_when_platform_has_instance() {
fn unknown_model_should_be_accepted_when_provider_is_registered() {
assert!(snapshot().contains_public_model_for_provider(
&PublicModelId::new("future-model").expect("model"),
&ProviderKind::new("openai").expect("provider"),
@@ -20,13 +20,12 @@ use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::{
RuntimeSnapshotCompileError, RuntimeSnapshotCompiler, RuntimeSnapshotHandle,
RuntimeSnapshotPublisher, SnapshotClientPolicyFacts, SnapshotControl, SnapshotFacts,
SnapshotProviderInstanceFacts, SnapshotRevisionStream, SnapshotSettingsFacts,
SnapshotStoreError, SnapshotStorePort, SnapshotSubscriptionError, SnapshotSubscriptionPort,
runtime_revision_needs_refresh,
SnapshotRevisionStream, SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort,
SnapshotSubscriptionError, SnapshotSubscriptionPort, runtime_revision_needs_refresh,
};
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ProviderInstance, ProviderKind, PublicModelId,
RuntimeSnapshot, UpstreamModelId,
ConfigRevision, ModelCapabilities, ProviderKind, PublicModelId, RuntimeSnapshot,
UpstreamModelId,
};
use gateway_core::task::WorkerKind;
@@ -87,6 +86,39 @@ struct PublishingCatalogProvider {
queries: AtomicUsize,
}
struct UnavailableCatalogProvider;
#[async_trait]
impl Provider for UnavailableCatalogProvider {
fn name(&self) -> &'static str {
"alpha"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::new(0)
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
))
}
async fn execute(
&self,
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
))
}
}
#[async_trait]
impl Provider for PublishingCatalogProvider {
fn name(&self) -> &'static str {
@@ -99,7 +131,6 @@ impl Provider for PublishingCatalogProvider {
async fn query_model_capabilities(
&self,
_: &ProviderInstance,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
if self.queries.fetch_add(1, Ordering::SeqCst) == 0 {
self.generation.store(1, Ordering::SeqCst);
@@ -146,7 +177,11 @@ fn compiler_should_reject_revision_changed_during_consistent_read() {
#[test]
fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
let compiler = compiler(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))));
let providers =
ProviderRegistry::new([Arc::new(UnavailableCatalogProvider) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
let snapshot = block_on(compiler.compile()).expect("compile snapshot");
let provider = ProviderKind::new("alpha").expect("provider");
@@ -302,12 +337,6 @@ fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts
BTreeMap::from([("public-model".to_owned(), "upstream-model".to_owned())]),
)]),
),
vec![SnapshotProviderInstanceFacts::new(
"inst_alpha",
"alpha",
"https://alpha.example.invalid",
true,
)],
vec![SnapshotClientPolicyFacts::new(
ClientApiKeyId::new("key_one").expect("key ID"),
PlaintextClientApiKey::new("sk_test").expect("plaintext key"),
+3 -8
View File
@@ -1,6 +1,6 @@
//! 多 Provider 网关的 PostgreSQL 持久化与 Redis 协调 adapter。
//!
//! 业务规则与 port 由 `gateway-core` 拥有。本 crate 只负责把终态的八张业务表
//! 业务规则与 port 由 `gateway-core` 拥有。本 crate 只负责把终态的七张业务表
//! 和可丢失 Redis 状态映射为明确的基础设施操作。
use std::sync::Arc;
@@ -19,7 +19,7 @@ use gateway_admin::ports::store::{
};
use gateway_core::CoreStorePorts;
use gateway_core::health::{HealthProbe, HealthState};
use gateway_core::provider_ports::{ProviderCatalogPorts, ProviderStorePorts};
use gateway_core::provider_ports::ProviderStorePorts;
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDisabledReason, WorkerId,
WorkerKind, WorkerLeaderLeasePort, WorkerLeaseRequest, WorkerRegistration, WorkerRunnable,
@@ -256,10 +256,6 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let provider_catalog = ProviderCatalogPorts::new(
Arc::new(postgres::PgConfigCatalogRepository::new(pool.clone())),
credential_state.clone(),
);
let runtime_policy = Arc::new(postgres::PgRuntimeSettingsRepository::new(pool.clone()));
let oauth_pending = Arc::new(redis::RedisOAuthPendingFlowRepository::new(
redis_connection.clone(),
@@ -276,7 +272,6 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
REDIS_NAMESPACE,
)?,
}),
Arc::new(postgres::PgAdminCatalogStore::new(pool.clone())),
Arc::new(postgres::PgAdminClientKeyStore::new(pool.clone())),
Arc::new(postgres::PgAdminObservabilityStore::new(pool.clone())),
Arc::new(AdminSettingsStoreAdapter {
@@ -311,7 +306,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
let provider_ports = ProviderStorePorts::new(
account_store,
provider_leases,
provider_catalog,
credential_state.clone(),
credential_state,
cooldowns,
runtime_policy,
@@ -1,593 +0,0 @@
//! `provider_instances` 的 PostgreSQL owner。
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_admin::{
model::{
MutationContext,
catalog::{
CreateProviderInstance as AdminCreateProviderInstance,
DeleteProviderInstance as AdminDeleteProviderInstance,
ProviderInstance as AdminProviderInstance, ProviderInstanceCatalog,
ProviderInstanceDetail, ProviderInstanceMutation, SetProviderInstanceEnabled,
UpdateProviderInstance as AdminUpdateProviderInstance,
},
},
ports::store::{AdminStoreResult, CatalogStore},
};
use gateway_core::provider_ports::{
ProviderInstanceCatalogPort, ProviderInstanceConfig, ProviderStoreError, ProviderStoreErrorKind,
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use sqlx::{PgPool, Postgres, Transaction};
use crate::{
StoreError, StoreResult, admin_revision, admin_store_error, mutation_audit,
postgres_unavailable, require_nonempty, store_revision,
};
use super::{ControlPlaneRepository, PgControlPlaneRepository};
const ENTITY: &str = "provider instance";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInstanceRecord {
pub id: String,
pub provider_kind: String,
pub name: String,
pub base_url: String,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewProviderInstance {
pub id: String,
pub provider_kind: String,
pub name: String,
pub base_url: String,
}
impl NewProviderInstance {
pub fn validate(&self) -> StoreResult<()> {
validate_instance_fields(&self.id, &self.provider_kind, &self.name, &self.base_url)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProviderInstance {
pub id: String,
pub name: String,
pub base_url: String,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProviderInstanceDetails {
pub id: String,
pub name: String,
pub base_url: String,
}
impl UpdateProviderInstanceDetails {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "id", &self.id)?;
require_nonempty(ENTITY, "name", &self.name)?;
require_nonempty(ENTITY, "base_url", &self.base_url)
}
}
#[async_trait]
pub trait ConfigCatalogRepository: Send + Sync {
async fn get_provider_instance(&self, id: &str) -> StoreResult<Option<ProviderInstanceRecord>>;
async fn list_provider_instances(
&self,
include_disabled: bool,
) -> StoreResult<Vec<ProviderInstanceRecord>>;
async fn insert_provider_instance(&self, instance: NewProviderInstance) -> StoreResult<()>;
async fn update_provider_instance(&self, instance: UpdateProviderInstance)
-> StoreResult<bool>;
async fn delete_provider_instance(&self, id: &str) -> StoreResult<bool>;
}
#[derive(Clone)]
pub struct PgConfigCatalogRepository {
pool: PgPool,
}
impl PgConfigCatalogRepository {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ConfigCatalogRepository for PgConfigCatalogRepository {
async fn get_provider_instance(&self, id: &str) -> StoreResult<Option<ProviderInstanceRecord>> {
require_nonempty(ENTITY, "id", id)?;
let row = sqlx::query_as::<
_,
(
String,
String,
String,
String,
bool,
DateTime<Utc>,
DateTime<Utc>,
),
>(
"select id, provider_kind, name, base_url, enabled, created_at, updated_at
from provider_instances where id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|_| postgres_unavailable("get provider instance"))?;
Ok(row.map(provider_instance_from_row))
}
async fn list_provider_instances(
&self,
include_disabled: bool,
) -> StoreResult<Vec<ProviderInstanceRecord>> {
let rows = sqlx::query_as::<
_,
(
String,
String,
String,
String,
bool,
DateTime<Utc>,
DateTime<Utc>,
),
>(
"select id, provider_kind, name, base_url, enabled, created_at, updated_at
from provider_instances
where $1 or enabled
order by provider_kind, name, id",
)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_err(|_| postgres_unavailable("list provider instances"))?;
Ok(rows.into_iter().map(provider_instance_from_row).collect())
}
async fn insert_provider_instance(&self, instance: NewProviderInstance) -> StoreResult<()> {
instance.validate()?;
sqlx::query(
"insert into provider_instances
(id, provider_kind, name, base_url, enabled, created_at, updated_at)
values ($1, $2, $3, $4, true, now(), now())",
)
.bind(instance.id)
.bind(instance.provider_kind)
.bind(instance.name)
.bind(instance.base_url)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("insert provider instance"))?;
Ok(())
}
async fn update_provider_instance(
&self,
instance: UpdateProviderInstance,
) -> StoreResult<bool> {
require_nonempty(ENTITY, "id", &instance.id)?;
require_nonempty(ENTITY, "name", &instance.name)?;
require_nonempty(ENTITY, "base_url", &instance.base_url)?;
let result = sqlx::query(
"update provider_instances
set name = $2, base_url = $3, enabled = $4, updated_at = now()
where id = $1",
)
.bind(instance.id)
.bind(instance.name)
.bind(instance.base_url)
.bind(instance.enabled)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("update provider instance"))?;
Ok(result.rows_affected() == 1)
}
async fn delete_provider_instance(&self, id: &str) -> StoreResult<bool> {
require_nonempty(ENTITY, "id", id)?;
let result = sqlx::query("delete from provider_instances where id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("delete provider instance"))?;
Ok(result.rows_affected() == 1)
}
}
impl ProviderInstanceCatalogPort for PgConfigCatalogRepository {
fn list_instances<'a>(
&'a self,
provider_kind: &'a ProviderKind,
include_disabled: bool,
) -> futures::future::BoxFuture<'a, Result<Vec<ProviderInstanceConfig>, ProviderStoreError>>
{
Box::pin(async move {
self.list_provider_instances(include_disabled)
.await
.map_err(|_| {
ProviderStoreError::new(
ProviderStoreErrorKind::Unavailable,
"list Provider instances",
)
})?
.into_iter()
.filter(|instance| instance.provider_kind == provider_kind.as_str())
.map(|instance| {
let id = ProviderInstanceId::new(instance.id).map_err(|_| {
ProviderStoreError::new(
ProviderStoreErrorKind::InvalidData,
"decode Provider instance ID",
)
})?;
let kind = ProviderKind::new(instance.provider_kind).map_err(|_| {
ProviderStoreError::new(
ProviderStoreErrorKind::InvalidData,
"decode Provider kind",
)
})?;
Ok(ProviderInstanceConfig::new(
id,
kind,
instance.base_url,
instance.enabled,
))
})
.collect()
})
}
}
/// Admin 用例所需的 Provider instance 事务能力。
#[derive(Clone)]
pub struct PgAdminCatalogStore {
catalog: PgConfigCatalogRepository,
control_plane: PgControlPlaneRepository,
}
impl PgAdminCatalogStore {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self {
catalog: PgConfigCatalogRepository::new(pool.clone()),
control_plane: PgControlPlaneRepository::new(pool),
}
}
async fn revision(&self) -> AdminStoreResult<gateway_admin::model::Revision> {
self.control_plane
.load_control_plane()
.await
.map_err(|error| admin_store_error("provider instance", error))
.and_then(|snapshot| admin_revision(snapshot.settings.config_revision))
}
async fn load_mutation(
&self,
revision: crate::Revision,
id: &ProviderInstanceId,
) -> AdminStoreResult<ProviderInstanceMutation> {
let instance = self
.catalog
.get_provider_instance(id.as_str())
.await
.map_err(|error| admin_store_error("provider instance", error))?
.map(admin_provider_instance)
.transpose()?;
Ok(ProviderInstanceMutation {
config_revision: admin_revision(revision)?,
instance,
})
}
}
#[async_trait]
impl CatalogStore for PgAdminCatalogStore {
async fn list_provider_instances(
&self,
include_disabled: bool,
) -> AdminStoreResult<ProviderInstanceCatalog> {
let config_revision = self.revision().await?;
let items = self
.catalog
.list_provider_instances(include_disabled)
.await
.map_err(|error| admin_store_error("provider instance", error))?
.into_iter()
.map(admin_provider_instance)
.collect::<AdminStoreResult<Vec<_>>>()?;
Ok(ProviderInstanceCatalog {
config_revision,
items,
})
}
async fn load_provider_instance(
&self,
id: &ProviderInstanceId,
) -> AdminStoreResult<Option<ProviderInstanceDetail>> {
let (revision, instance) = futures::try_join!(self.revision(), async {
self.catalog
.get_provider_instance(id.as_str())
.await
.map_err(|error| admin_store_error("provider instance", error))?
.map(admin_provider_instance)
.transpose()
},)?;
Ok(instance.map(|item| ProviderInstanceDetail {
config_revision: revision,
item,
}))
}
async fn create_provider_instance(
&self,
command: AdminCreateProviderInstance,
context: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
let id = command.id;
let revision = self
.control_plane
.create_provider_instance(
store_revision(command.expected_config_revision)?,
NewProviderInstance {
id: id.as_str().to_owned(),
provider_kind: command.provider_kind.as_str().to_owned(),
name: command.name,
base_url: command.base_url,
},
mutation_audit(
context,
"create",
"provider_instance",
id.as_str(),
vec![
"provider_kind".to_owned(),
"name".to_owned(),
"base_url".to_owned(),
"enabled".to_owned(),
],
),
)
.await
.map_err(|error| admin_store_error("provider instance", error))?;
self.load_mutation(revision, &id).await
}
async fn update_provider_instance(
&self,
command: AdminUpdateProviderInstance,
context: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
let id = command.id;
let revision = self
.control_plane
.update_provider_instance(
store_revision(command.expected_config_revision)?,
UpdateProviderInstanceDetails {
id: id.as_str().to_owned(),
name: command.name,
base_url: command.base_url,
},
mutation_audit(
context,
"update",
"provider_instance",
id.as_str(),
vec!["name".to_owned(), "base_url".to_owned()],
),
)
.await
.map_err(|error| admin_store_error("provider instance", error))?;
self.load_mutation(revision, &id).await
}
async fn set_provider_instance_enabled(
&self,
command: SetProviderInstanceEnabled,
context: &MutationContext,
) -> AdminStoreResult<ProviderInstanceMutation> {
let id = command.id;
let revision = self
.control_plane
.set_provider_instance_enabled(
store_revision(command.expected_config_revision)?,
id.as_str(),
command.enabled,
mutation_audit(
context,
if command.enabled { "enable" } else { "disable" },
"provider_instance",
id.as_str(),
vec!["enabled".to_owned()],
),
)
.await
.map_err(|error| admin_store_error("provider instance", error))?;
self.load_mutation(revision, &id).await
}
async fn delete_provider_instance(
&self,
command: AdminDeleteProviderInstance,
context: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::Revision> {
self.control_plane
.delete_provider_instance(
store_revision(command.expected_config_revision)?,
command.id.as_str(),
mutation_audit(
context,
"delete",
"provider_instance",
command.id.as_str(),
Vec::new(),
),
)
.await
.map_err(|error| admin_store_error("provider instance", error))
.and_then(admin_revision)
}
}
fn admin_provider_instance(
record: ProviderInstanceRecord,
) -> AdminStoreResult<AdminProviderInstance> {
Ok(AdminProviderInstance {
id: ProviderInstanceId::new(record.id).map_err(|_| {
admin_store_error(
"provider instance",
StoreError::InvalidData {
entity: ENTITY,
message: "persisted instance ID is invalid".to_owned(),
},
)
})?,
provider_kind: ProviderKind::new(record.provider_kind).map_err(|_| {
admin_store_error(
"provider instance",
StoreError::InvalidData {
entity: ENTITY,
message: "persisted provider kind is invalid".to_owned(),
},
)
})?,
name: record.name,
base_url: record.base_url,
enabled: record.enabled,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
pub(crate) async fn insert_provider_instance_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
instance: &NewProviderInstance,
) -> StoreResult<()> {
instance.validate()?;
sqlx::query(
"insert into provider_instances
(id, provider_kind, name, base_url, enabled, created_at, updated_at)
values ($1, $2, $3, $4, true, now(), now())",
)
.bind(&instance.id)
.bind(&instance.provider_kind)
.bind(&instance.name)
.bind(&instance.base_url)
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("insert provider instance in transaction"))?;
Ok(())
}
pub(crate) async fn update_provider_instance_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
instance: &UpdateProviderInstanceDetails,
) -> StoreResult<()> {
instance.validate()?;
let result = sqlx::query(
"update provider_instances
set name = $2, base_url = $3, updated_at = now()
where id = $1",
)
.bind(&instance.id)
.bind(&instance.name)
.bind(&instance.base_url)
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update provider instance in transaction"))?;
require_changed(result.rows_affected(), &instance.id)
}
pub(crate) async fn set_provider_instance_enabled_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
id: &str,
enabled: bool,
) -> StoreResult<()> {
require_nonempty(ENTITY, "id", id)?;
let result =
sqlx::query("update provider_instances set enabled = $2, updated_at = now() where id = $1")
.bind(id)
.bind(enabled)
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("set provider instance state in transaction"))?;
require_changed(result.rows_affected(), id)
}
pub(crate) async fn delete_provider_instance_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
id: &str,
) -> StoreResult<()> {
require_nonempty(ENTITY, "id", id)?;
let result = sqlx::query("delete from provider_instances where id = $1")
.bind(id)
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("delete provider instance in transaction"))?;
require_changed(result.rows_affected(), id)
}
fn require_changed(rows_affected: u64, id: &str) -> StoreResult<()> {
if rows_affected == 1 {
Ok(())
} else {
Err(StoreError::NotFound {
entity: ENTITY,
id: id.to_owned(),
})
}
}
type ProviderInstanceRow = (
String,
String,
String,
String,
bool,
DateTime<Utc>,
DateTime<Utc>,
);
fn provider_instance_from_row(row: ProviderInstanceRow) -> ProviderInstanceRecord {
ProviderInstanceRecord {
id: row.0,
provider_kind: row.1,
name: row.2,
base_url: row.3,
enabled: row.4,
created_at: row.5,
updated_at: row.6,
}
}
fn validate_instance_fields(id: &str, kind: &str, name: &str, base_url: &str) -> StoreResult<()> {
require_nonempty(ENTITY, "id", id)?;
require_nonempty(ENTITY, "provider_kind", kind)?;
require_nonempty(ENTITY, "name", name)?;
require_nonempty(ENTITY, "base_url", base_url)?;
let valid_kind = kind.bytes().enumerate().all(|(index, byte)| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| (index > 0 && matches!(byte, b'-' | b'_'))
});
if !valid_kind {
return Err(StoreError::InvalidData {
entity: ENTITY,
message: "provider_kind must be a stable lowercase slug".to_owned(),
});
}
Ok(())
}
@@ -129,7 +129,6 @@ impl NewModelRequest {
pub struct ModelRequestAttemptStart {
pub model_request_id: String,
pub attempt_count: u32,
pub provider_instance_id: String,
pub provider_kind: String,
pub provider_account_id: Option<String>,
pub provider_account_ref: Option<String>,
@@ -142,7 +141,6 @@ impl ModelRequestAttemptStart {
pub fn validate(&self) -> StoreResult<()> {
for (field, value) in [
("model_request_id", self.model_request_id.as_str()),
("provider_instance_id", self.provider_instance_id.as_str()),
("provider_kind", self.provider_kind.as_str()),
("upstream_model_id", self.upstream_model_id.as_str()),
("upstream_transport", self.upstream_transport.as_str()),
@@ -388,21 +386,19 @@ impl ModelRequestRepository for PgExecutionStore {
attempt.validate()?;
let count = sqlx::query_scalar::<_, i32>(
"update model_requests
set provider_instance_id = $2,
provider_kind = $3,
provider_account_id = $4,
provider_account_ref = $5,
upstream_model_id = $6,
upstream_transport = $7,
http_version = $8,
attempt_count = $9,
set provider_kind = $2,
provider_account_id = $3,
provider_account_ref = $4,
upstream_model_id = $5,
upstream_transport = $6,
http_version = $7,
attempt_count = $8,
upstream_send_state = 'not_sent'
where id = $1 and outcome = 'running' and downstream_committed_at is null
and $9 = attempt_count + 1
and $8 = attempt_count + 1
returning attempt_count",
)
.bind(&attempt.model_request_id)
.bind(attempt.provider_instance_id)
.bind(attempt.provider_kind)
.bind(attempt.provider_account_id)
.bind(attempt.provider_account_ref)
@@ -665,7 +661,6 @@ impl ExecutionStore for PgExecutionStore {
.begin_model_request_attempt(ModelRequestAttemptStart {
model_request_id: attempt.request_id.as_str().to_owned(),
attempt_count: expected_count,
provider_instance_id: attempt.provider_instance_id.as_str().to_owned(),
provider_kind: attempt.provider_kind.as_str().to_owned(),
provider_account_id: attempt
.provider_account_id
@@ -751,7 +746,6 @@ impl ExecutionStore for PgExecutionStore {
level: super::OpsEventLevel::Warning,
component: "routing".to_owned(),
operation: failure.trigger.as_str().to_owned(),
provider_instance_id: Some(failure.instance_id.as_str().to_owned()),
provider_kind: Some(failure.provider_kind.as_str().to_owned()),
provider_account_id: failure.account_id.as_ref().map(|id| id.as_str().to_owned()),
provider_account_ref: failure.account_id.as_ref().map(|id| id.as_str().to_owned()),
@@ -10,7 +10,7 @@ use gateway_core::{
credential::ProviderAccountId,
},
error::SafeUpstreamValue,
routing::{ProviderInstanceId, ProviderKind},
routing::ProviderKind,
};
use sqlx::PgPool;
@@ -22,7 +22,6 @@ pub struct ModelRequestHistoryRecord {
pub client_api_key_ref: String,
pub requested_model_id: String,
pub provider_kind: Option<String>,
pub provider_instance_id: Option<String>,
pub provider_account_ref: Option<String>,
pub upstream_model_id: Option<String>,
pub outcome: String,
@@ -112,16 +111,11 @@ impl ModelRequestHistoryRepository for PgHistoryRepository {
"caller_client_api_key_ref",
caller_client_api_key_ref,
)?;
let row = sqlx::query_as::<_, (String, String, String, String)>(
"select mr.provider_kind, mr.provider_instance_id,
mr.provider_account_id, mr.upstream_response_id
let row = sqlx::query_as::<_, (String, String, String)>(
"select mr.provider_kind, mr.provider_account_id, mr.upstream_response_id
from model_requests mr
join provider_instances instance
on instance.id = mr.provider_instance_id
and instance.provider_kind = mr.provider_kind
join provider_accounts account
on account.id = mr.provider_account_id
and account.provider_instance_id = mr.provider_instance_id
and account.provider_kind = mr.provider_kind
where mr.client_response_id = $1
and mr.client_api_key_ref = $2
@@ -168,8 +162,8 @@ async fn fetch_history(
"id" => {
sqlx::query_as::<_, HistoryRow>(
"select id, client_api_key_ref, requested_model_id, provider_kind,
provider_instance_id, provider_account_ref,
upstream_model_id, outcome, client_response_id, upstream_response_id,
provider_account_ref, upstream_model_id, outcome,
client_response_id, upstream_response_id,
started_at, completed_at
from model_requests where id = $1",
)
@@ -190,8 +184,8 @@ async fn fetch_history_by_client_response(
) -> StoreResult<Option<ModelRequestHistoryRecord>> {
let row = sqlx::query_as::<_, HistoryRow>(
"select id, client_api_key_ref, requested_model_id, provider_kind,
provider_instance_id, provider_account_ref,
upstream_model_id, outcome, client_response_id, upstream_response_id,
provider_account_ref, upstream_model_id, outcome,
client_response_id, upstream_response_id,
started_at, completed_at
from model_requests
where client_response_id = $1 and client_api_key_ref = $2",
@@ -211,7 +205,6 @@ type HistoryRow = (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
String,
Option<String>,
Option<String>,
@@ -225,36 +218,32 @@ fn history_from_row(row: HistoryRow) -> ModelRequestHistoryRecord {
client_api_key_ref: row.1,
requested_model_id: row.2,
provider_kind: row.3,
provider_instance_id: row.4,
provider_account_ref: row.5,
upstream_model_id: row.6,
outcome: row.7,
client_response_id: row.8,
upstream_response_id: row.9,
started_at: row.10,
completed_at: row.11,
provider_account_ref: row.4,
upstream_model_id: row.5,
outcome: row.6,
client_response_id: row.7,
upstream_response_id: row.8,
started_at: row.9,
completed_at: row.10,
}
}
fn native_pin_from_row(
client_response_id: &str,
row: (String, String, String, String),
row: (String, String, String),
) -> StoreResult<NativeContinuationPin> {
let previous_response_id = PreviousResponseId::new(client_response_id.to_owned())
.map_err(|_| invalid_native_pin("invalid client response ID"))?;
let provider =
ProviderKind::new(row.0).map_err(|_| invalid_native_pin("invalid provider kind"))?;
let upstream_response_id = SafeUpstreamValue::new(row.3)
let upstream_response_id = SafeUpstreamValue::new(row.2)
.map_err(|_| invalid_native_pin("invalid upstream response ID"))?;
let instance = ProviderInstanceId::new(row.1)
.map_err(|_| invalid_native_pin("invalid provider instance ID"))?;
let account = ProviderAccountId::new(row.2)
let account = ProviderAccountId::new(row.1)
.map_err(|_| invalid_native_pin("invalid provider account ID"))?;
Ok(NativeContinuationPin::new(
previous_response_id,
upstream_response_id,
provider,
instance,
account,
)
.with_scope(NativeContinuationScope::Persisted))
@@ -8,7 +8,6 @@ use crate::{Revision, StoreBackend, StoreError, StoreResult, postgres_unavailabl
mod admin_security_audit;
mod admission_recovery;
mod client_keys;
mod config_catalog;
mod execution;
mod history;
mod observability;
@@ -21,7 +20,6 @@ mod snapshot;
pub use admin_security_audit::*;
pub use admission_recovery::*;
pub use client_keys::*;
pub use config_catalog::*;
pub use execution::*;
pub use history::*;
pub use observability::*;
@@ -73,35 +71,6 @@ pub trait ControlPlaneRepository: Send + Sync {
replacement: ControlPlaneReplacement,
) -> StoreResult<ControlPlaneSnapshot>;
async fn create_provider_instance(
&self,
expected_revision: Revision,
instance: NewProviderInstance,
audit: AdminAuditEvent,
) -> StoreResult<Revision>;
async fn update_provider_instance(
&self,
expected_revision: Revision,
instance: UpdateProviderInstanceDetails,
audit: AdminAuditEvent,
) -> StoreResult<Revision>;
async fn set_provider_instance_enabled(
&self,
expected_revision: Revision,
id: &str,
enabled: bool,
audit: AdminAuditEvent,
) -> StoreResult<Revision>;
async fn delete_provider_instance(
&self,
expected_revision: Revision,
id: &str,
audit: AdminAuditEvent,
) -> StoreResult<Revision>;
async fn create_client_api_key(
&self,
expected_revision: Revision,
@@ -205,66 +174,6 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
}
}
async fn create_provider_instance(
&self,
expected_revision: Revision,
instance: NewProviderInstance,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::CreateProviderInstance(instance),
audit,
)
.await
}
async fn update_provider_instance(
&self,
expected_revision: Revision,
instance: UpdateProviderInstanceDetails,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::UpdateProviderInstance(instance),
audit,
)
.await
}
async fn set_provider_instance_enabled(
&self,
expected_revision: Revision,
id: &str,
enabled: bool,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::SetProviderInstanceEnabled {
id: id.to_owned(),
enabled,
},
audit,
)
.await
}
async fn delete_provider_instance(
&self,
expected_revision: Revision,
id: &str,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::DeleteProviderInstance(id.to_owned()),
audit,
)
.await
}
async fn create_client_api_key(
&self,
expected_revision: Revision,
@@ -327,10 +236,6 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
}
enum ControlPlaneMutation {
CreateProviderInstance(NewProviderInstance),
UpdateProviderInstance(UpdateProviderInstanceDetails),
SetProviderInstanceEnabled { id: String, enabled: bool },
DeleteProviderInstance(String),
CreateClientApiKey(NewClientApiKey),
UpdateClientApiKey(UpdateClientApiKeyDetails),
SetClientApiKeyEnabled { id: String, enabled: bool },
@@ -353,19 +258,6 @@ impl PgControlPlaneRepository {
let revision =
bump_config_revision_in_transaction(&mut transaction, expected_revision).await?;
match mutation {
ControlPlaneMutation::CreateProviderInstance(instance) => {
insert_provider_instance_in_transaction(&mut transaction, &instance).await?;
}
ControlPlaneMutation::UpdateProviderInstance(instance) => {
update_provider_instance_in_transaction(&mut transaction, &instance).await?;
}
ControlPlaneMutation::SetProviderInstanceEnabled { id, enabled } => {
set_provider_instance_enabled_in_transaction(&mut transaction, &id, enabled)
.await?;
}
ControlPlaneMutation::DeleteProviderInstance(id) => {
delete_provider_instance_in_transaction(&mut transaction, &id).await?;
}
ControlPlaneMutation::CreateClientApiKey(key) => {
insert_client_api_key_in_transaction(&mut transaction, &key).await?;
}
@@ -377,7 +377,6 @@ pub struct ProviderAccountMetrics {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountUsageObservation {
pub account_id: String,
pub provider_instance_id: String,
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
@@ -479,7 +478,6 @@ pub struct UsageRecord {
pub client_transport: String,
pub requested_model_id: String,
pub input_token_estimate: u64,
pub provider_instance_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -549,7 +547,6 @@ pub struct UsageAttemptObservation {
pub attempt_index: u32,
pub component: String,
pub operation: String,
pub provider_instance_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub upstream_model_id: Option<String>,
@@ -621,7 +618,6 @@ pub struct OpsErrorRecord {
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub provider_instance_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub upstream_model_id: Option<String>,
@@ -1189,7 +1185,6 @@ fn admin_dashboard_account_usage(
) -> AdminStoreResult<admin_observability::DashboardAccountUsage> {
Ok(admin_observability::DashboardAccountUsage {
account_id: usage.account_id,
provider_instance_id: usage.provider_instance_id,
provider_kind: usage.provider_kind,
name: usage.name,
email: usage.email,
@@ -1310,7 +1305,6 @@ fn admin_usage_record(record: UsageRecord) -> AdminStoreResult<admin_observabili
client_transport: record.client_transport,
requested_model_id: record.requested_model_id,
input_token_estimate: record.input_token_estimate,
provider_instance_id: record.provider_instance_id,
provider_kind: record.provider_kind,
provider_account_ref: record.provider_account_ref,
provider_account_name: record.provider_account_name,
@@ -1399,7 +1393,6 @@ fn admin_usage_attempt(
attempt_index: attempt.attempt_index,
component: attempt.component,
operation: attempt.operation,
provider_instance_id: attempt.provider_instance_id,
provider_kind: attempt.provider_kind,
provider_account_ref: attempt.provider_account_ref,
upstream_model_id: attempt.upstream_model_id,
@@ -1487,7 +1480,6 @@ fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
client_api_key_ref: error.client_api_key_ref,
component: error.component,
operation: error.operation,
provider_instance_id: error.provider_instance_id,
provider_kind: error.provider_kind,
provider_account_ref: error.provider_account_ref,
upstream_model_id: error.upstream_model_id,
@@ -2046,7 +2038,7 @@ async fn provider_account_usage(
}
let mut statement = QueryBuilder::<Postgres>::new(
"select pa.id, pa.provider_instance_id, pa.provider_kind, pa.name, pa.email,
"select pa.id, pa.provider_kind, pa.name, pa.email,
pa.plan_type, pa.enabled, pa.availability,
count(mr.id)::bigint as request_count,
count(mr.id) filter (where mr.outcome = 'succeeded')::bigint as success_count,
@@ -2082,7 +2074,7 @@ async fn provider_account_usage(
statement.push("::text[])");
}
statement.push(
" group by pa.id, pa.provider_instance_id, pa.provider_kind, pa.name, pa.email,
" group by pa.id, pa.provider_kind, pa.name, pa.email,
pa.plan_type, pa.enabled, pa.availability
order by max(mr.started_at) desc nulls last, pa.name, pa.id limit ",
);
@@ -2262,7 +2254,6 @@ fn provider_account_from_row(
) -> StoreResult<ProviderAccountUsageObservation> {
Ok(ProviderAccountUsageObservation {
account_id: get(row, "id")?,
provider_instance_id: get(row, "provider_instance_id")?,
provider_kind: get(row, "provider_kind")?,
name: get(row, "name")?,
email: get(row, "email")?,
@@ -2291,7 +2282,7 @@ fn provider_account_from_row(
const USAGE_RECORD_SELECT: &str =
"select mr.id, mr.client_api_key_ref, mr.config_revision, mr.protocol, mr.operation,
mr.endpoint, mr.client_transport, mr.requested_model_id,
mr.input_token_estimate, mr.provider_instance_id, mr.provider_kind, mr.provider_account_ref,
mr.input_token_estimate, mr.provider_kind, mr.provider_account_ref,
pa.name as provider_account_name, pa.email as provider_account_email,
mr.upstream_model_id, mr.upstream_transport, mr.http_version, mr.websocket_pool,
mr.attempt_count, mr.upstream_send_state, mr.downstream_committed_at,
@@ -2405,7 +2396,7 @@ async fn usage_record_detail(pool: &PgPool, request_id: &str) -> StoreResult<Usa
let request = usage_record_from_row(&row)?;
let rows = sqlx::query(
"select id, attempt_index, component, operation,
provider_instance_id, provider_kind, provider_account_ref, upstream_model_id,
provider_kind, provider_account_ref, upstream_model_id,
failure_kind, status_code, provider_error_code, retry_after_ms,
upstream_request_id, latency_ms, message, created_at
from ops_events where model_request_id = $1
@@ -2434,7 +2425,6 @@ fn intermediate_attempt_from_row(
attempt_index: to_u32(get(row, "attempt_index")?)?,
component: get(row, "component")?,
operation: get(row, "operation")?,
provider_instance_id: get(row, "provider_instance_id")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
upstream_model_id: get(row, "upstream_model_id")?,
@@ -2469,7 +2459,6 @@ fn final_attempt_from_request(request: &UsageRecord) -> UsageAttemptObservation
attempt_index: request.attempt_count,
component: "model_request".to_owned(),
operation: request.operation.clone(),
provider_instance_id: request.provider_instance_id.clone(),
provider_kind: request.provider_kind.clone(),
provider_account_ref: request.provider_account_ref.clone(),
upstream_model_id: request.upstream_model_id.clone(),
@@ -2622,7 +2611,7 @@ const OPS_ERRORS_CTE: &str = "with errors as (
mr.id as event_id, mr.id as request_id,
nullif(mr.attempt_count, 0) as attempt_index,
mr.client_api_key_ref, 'model_request'::text as component, mr.operation,
mr.provider_instance_id, mr.provider_kind,
mr.provider_kind,
mr.provider_account_ref, mr.upstream_model_id, mr.upstream_transport,
coalesce(mr.error_kind, 'failed') as failure_kind,
coalesce(mr.upstream_status_code, mr.client_status_code) as status_code,
@@ -2635,7 +2624,7 @@ const OPS_ERRORS_CTE: &str = "with errors as (
union all
select 'ops_event'::text, oe.id, oe.model_request_id, oe.attempt_index,
mr.client_api_key_ref, oe.component, oe.operation,
oe.provider_instance_id, oe.provider_kind, oe.provider_account_ref,
oe.provider_kind, oe.provider_account_ref,
oe.upstream_model_id, null::text, oe.failure_kind, oe.status_code,
oe.provider_error_code, mr.client_response_id, oe.upstream_request_id,
oe.latency_ms, oe.message, oe.occurrence_count, oe.created_at,
@@ -2649,8 +2638,8 @@ async fn list_ops_errors(pool: &PgPool, query: OpsErrorQuery) -> StoreResult<Ops
let mut statement = QueryBuilder::<Postgres>::new(OPS_ERRORS_CTE);
statement.push(
" select source, event_id, request_id, attempt_index, client_api_key_ref,
component, operation, provider_instance_id,
provider_kind, provider_account_ref, upstream_model_id, upstream_transport,
component, operation, provider_kind, provider_account_ref,
upstream_model_id, upstream_transport,
failure_kind, status_code, provider_error_code, client_response_id,
upstream_request_id, latency_ms, message, occurrence_count, occurred_at,
stable_sort_id
@@ -2775,7 +2764,6 @@ fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<UsageRecord
client_transport: get(row, "client_transport")?,
requested_model_id: get(row, "requested_model_id")?,
input_token_estimate: unsigned(row, "input_token_estimate")?,
provider_instance_id: get(row, "provider_instance_id")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
provider_account_name: get(row, "provider_account_name")?,
@@ -2843,7 +2831,6 @@ fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<OpsErrorRecord
client_api_key_ref: get(row, "client_api_key_ref")?,
component: get(row, "component")?,
operation: get(row, "operation")?,
provider_instance_id: get(row, "provider_instance_id")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
upstream_model_id: get(row, "upstream_model_id")?,
@@ -32,7 +32,6 @@ pub struct OpsEvent {
pub level: OpsEventLevel,
pub component: String,
pub operation: String,
pub provider_instance_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_id: Option<String>,
pub provider_account_ref: Option<String>,
@@ -110,13 +109,12 @@ impl OpsEventRepository for PgOpsEventRepository {
sqlx::query(
"insert into ops_events (
id, model_request_id, attempt_index, level, component, operation,
provider_instance_id, provider_kind,
provider_account_id, provider_account_ref, upstream_model_id,
provider_kind, provider_account_id, provider_account_ref, upstream_model_id,
failure_kind, status_code, provider_error_code, retry_after_ms,
upstream_request_id, latency_ms, message, occurrence_count, created_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20
$14, $15, $16, $17, $18, $19
)",
)
.bind(event.id)
@@ -131,7 +129,6 @@ impl OpsEventRepository for PgOpsEventRepository {
.bind(event.level.as_str())
.bind(event.component)
.bind(event.operation)
.bind(event.provider_instance_id)
.bind(event.provider_kind)
.bind(event.provider_account_id)
.bind(event.provider_account_ref)
@@ -16,7 +16,7 @@ use gateway_admin::{
AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
AccountRecord, AccountSort as AdminAccountSort,
AccountSortField as AdminAccountSortField, AccountStatus as AdminAccountStatus,
AccountSummary, AccountUsage, DeleteAccount, SetAccountEnabled,
AccountSummary, AccountUsage, DeleteAccounts, SetAccountEnabled,
SortDirection as AdminSortDirection,
},
observability::{
@@ -43,7 +43,7 @@ use gateway_core::engine::credential::{
QuotaWriteOutcome,
};
use gateway_core::error::{StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use gateway_core::routing::ProviderKind;
use crate::{
ConflictKind, JsonObject, Revision, StoreError, StoreResult, admin_revision, admin_store_error,
@@ -67,18 +67,15 @@ const ADMIN_USAGE_CHUNK_SIZE: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountAdminScope {
pub provider_kind: String,
pub provider_instance_id: String,
}
impl ProviderAccountAdminScope {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?;
require_nonempty(ENTITY, "provider_instance_id", &self.provider_instance_id)
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)
}
fn contains(&self, account: &NewProviderAccount) -> bool {
account.provider_kind == self.provider_kind
&& account.provider_instance_id == self.provider_instance_id
}
}
@@ -124,7 +121,6 @@ impl ProviderAccountAvailability {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountSummary {
pub id: String,
pub provider_instance_id: String,
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
@@ -169,7 +165,6 @@ impl fmt::Debug for ProviderAccountRecord {
#[derive(Clone)]
pub struct NewProviderAccount {
pub id: String,
pub provider_instance_id: String,
pub provider_kind: String,
pub name: String,
pub email: Option<String>,
@@ -200,7 +195,6 @@ impl fmt::Debug for NewProviderAccount {
formatter
.debug_struct("NewProviderAccount")
.field("id", &self.id)
.field("provider_instance_id", &self.provider_instance_id)
.field("provider_kind", &self.provider_kind)
.field("name", &self.name)
.field("enabled", &self.enabled)
@@ -214,7 +208,6 @@ impl fmt::Debug for NewProviderAccount {
impl NewProviderAccount {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "id", &self.id)?;
require_nonempty(ENTITY, "provider_instance_id", &self.provider_instance_id)?;
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?;
require_nonempty(ENTITY, "name", &self.name)?;
require_nonempty(ENTITY, "upstream_user_id", &self.upstream_user_id)?;
@@ -413,7 +406,7 @@ pub trait ProviderAccountRepository: Send + Sync {
async fn load_provider_account(&self, id: &str) -> StoreResult<Option<ProviderAccountRecord>>;
async fn list_provider_accounts(
&self,
provider_instance_id: Option<&str>,
provider_kind: Option<&str>,
include_disabled: bool,
) -> StoreResult<Vec<ProviderAccountSummary>>;
async fn insert_provider_account(&self, account: NewProviderAccount) -> StoreResult<()>;
@@ -500,20 +493,20 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
async fn list_provider_accounts(
&self,
provider_instance_id: Option<&str>,
provider_kind: Option<&str>,
include_disabled: bool,
) -> StoreResult<Vec<ProviderAccountSummary>> {
let rows = sqlx::query(
"select id, provider_instance_id, provider_kind, name, email, upstream_user_id,
"select id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, credential_revision, has_refresh_token,
access_token_expires_at, next_refresh_at, enabled, availability,
availability_reason, cooldown_until, availability_observed_at,
quota_observed_at, created_at, updated_at
from provider_accounts
where ($1::text is null or provider_instance_id = $1) and ($2 or enabled)
order by provider_kind, provider_instance_id, name, id",
where ($1::text is null or provider_kind = $1) and ($2 or enabled)
order by provider_kind, name, id",
)
.bind(provider_instance_id)
.bind(provider_kind)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
@@ -525,18 +518,17 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
account.validate()?;
sqlx::query(
"insert into provider_accounts (
id, provider_instance_id, provider_kind, name, email, upstream_user_id,
id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
availability, availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$14, null, $15, null, $16, null, now(), greatest(now(), $16)
$1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $10, $11, $12,
$13, null, $14, null, $15, null, now(), greatest(now(), $15)
)",
)
.bind(account.id)
.bind(account.provider_instance_id)
.bind(account.provider_kind)
.bind(account.name)
.bind(account.email)
@@ -734,7 +726,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
validate_admin_account_ids(&account_ids)?;
let rows = sqlx::query(ACCOUNT_SELECT_BY_IDS)
.bind(&account_ids)
.bind(&scope.provider_instance_id)
.bind(&scope.provider_kind)
.fetch_all(&self.pool)
.await
@@ -960,7 +951,6 @@ impl PgAdminAccountStore {
})?;
Ok(ProviderAccountAdminScope {
provider_kind: record.summary.provider_kind,
provider_instance_id: record.summary.provider_instance_id,
})
}
@@ -972,7 +962,6 @@ impl PgAdminAccountStore {
action: &str,
) -> AdminStoreResult<CredentialImportResult> {
let provider_kind = prepared.provider_kind.as_str().to_owned();
let provider_instance_id = prepared.provider_instance_id.as_str().to_owned();
let accounts = prepared
.credentials
.into_iter()
@@ -985,15 +974,14 @@ impl PgAdminAccountStore {
store_revision(expected_config_revision)?,
ImportProviderAccounts {
scope: ProviderAccountAdminScope {
provider_kind,
provider_instance_id: provider_instance_id.clone(),
provider_kind: provider_kind.clone(),
},
accounts,
audit: mutation_audit(
context,
action,
"provider_account",
&provider_instance_id,
&provider_kind,
vec!["credentials".to_owned()],
),
},
@@ -1027,7 +1015,6 @@ impl PgAdminAccountStore {
let account_id = prepared.account_id.clone();
let scope = ProviderAccountAdminScope {
provider_kind: prepared.provider_kind.as_str().to_owned(),
provider_instance_id: prepared.provider_instance_id.as_str().to_owned(),
};
let rotation = self
.accounts
@@ -1171,14 +1158,10 @@ impl AccountStore for PgAdminAccountStore {
provider_kind: &ProviderKind,
query: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
let provider_instance_id = query
.provider_instance_id
.as_ref()
.map(ProviderInstanceId::as_str);
let (control_plane, accounts) = futures::try_join!(
self.control_plane.load_control_plane(),
self.accounts
.list_provider_accounts(provider_instance_id, true),
.list_provider_accounts(Some(provider_kind.as_str()), true),
)
.map_err(|error| admin_store_error(ENTITY, error))?;
let mut accounts = accounts
@@ -1340,7 +1323,6 @@ impl AccountStore for PgAdminAccountStore {
expected_config_revision,
PreparedCredentialImport {
provider_kind: credential.provider_kind.clone(),
provider_instance_id: credential.provider_instance_id.clone(),
credentials: vec![credential],
},
context,
@@ -1433,23 +1415,35 @@ impl AccountStore for PgAdminAccountStore {
.and_then(admin_revision)
}
async fn delete_account(
async fn delete_accounts(
&self,
command: DeleteAccount,
command: DeleteAccounts,
context: &MutationContext,
) -> AdminStoreResult<AdminRevision> {
let scope = self.required_scope(&command.account_id).await?;
let first_account_id = command.account_ids.first().ok_or_else(|| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"account deletion requires at least one account ID",
)
})?;
let scope = self.required_scope(first_account_id).await?;
let audit_target = if command.account_ids.len() == 1 {
first_account_id.clone()
} else {
"provider_accounts".to_owned()
};
self.accounts
.delete_provider_accounts_admin(
store_revision(command.expected_config_revision)?,
DeleteProviderAccounts {
scope,
account_ids: vec![command.account_id.clone()],
account_ids: command.account_ids,
audit: mutation_audit(
context,
"delete",
"provider_account",
&command.account_id,
&audit_target,
Vec::new(),
),
},
@@ -1677,15 +1671,6 @@ const fn admin_account_status_name(status: AdminAccountStatus) -> &'static str {
fn admin_account_record(summary: ProviderAccountSummary) -> AdminStoreResult<AccountRecord> {
Ok(AccountRecord {
id: summary.id,
provider_instance_id: ProviderInstanceId::new(summary.provider_instance_id).map_err(
|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted Provider instance ID is invalid",
)
},
)?,
provider_kind: ProviderKind::new(summary.provider_kind).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
@@ -1716,7 +1701,6 @@ fn admin_account_record(summary: ProviderAccountSummary) -> AdminStoreResult<Acc
fn prepared_account(credential: PreparedCredentialCreate) -> StoreResult<NewProviderAccount> {
Ok(NewProviderAccount {
id: credential.account_id.as_str().to_owned(),
provider_instance_id: credential.provider_instance_id.as_str().to_owned(),
provider_kind: credential.provider_kind.as_str().to_owned(),
name: credential.name,
email: credential.email,
@@ -1856,14 +1840,14 @@ async fn upsert_provider_account_in_transaction(
account.validate()?;
let imported_id = sqlx::query_scalar::<_, String>(
"insert into provider_accounts (
id, provider_instance_id, provider_kind, name, email, upstream_user_id,
id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
availability, availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
$14, $15, $16, null, $17, null, now(), greatest(now(), $17)
$1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $10, $11, $12,
$13, $14, $15, null, $16, null, now(), greatest(now(), $16)
)
on conflict (
provider_kind,
@@ -1886,11 +1870,9 @@ async fn upsert_provider_account_in_transaction(
availability_observed_at = excluded.availability_observed_at,
quota_observed_at = null,
updated_at = greatest(now(), excluded.availability_observed_at)
where provider_accounts.provider_instance_id = excluded.provider_instance_id
returning id",
)
.bind(&account.id)
.bind(&account.provider_instance_id)
.bind(&account.provider_kind)
.bind(&account.name)
.bind(&account.email)
@@ -1938,21 +1920,20 @@ async fn rotate_provider_account_in_transaction(
) -> StoreResult<Revision> {
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = $5,
email = $6,
plan_type = $7,
provider_credentials_json = $8,
set name = $4,
email = $5,
plan_type = $6,
provider_credentials_json = $7,
credential_revision = credential_revision + 1,
has_refresh_token = $9,
access_token_expires_at = $10,
next_refresh_at = $11,
has_refresh_token = $8,
access_token_expires_at = $9,
next_refresh_at = $10,
updated_at = now()
where id = $1 and provider_instance_id = $2 and provider_kind = $3
and credential_revision = $4
where id = $1 and provider_kind = $2
and credential_revision = $3
returning credential_revision",
)
.bind(&update.account_id)
.bind(&scope.provider_instance_id)
.bind(&scope.provider_kind)
.bind(to_i64(update.expected_revision.get())?)
.bind(&profile.name)
@@ -1980,11 +1961,10 @@ async fn set_provider_account_enabled_in_transaction(
enabled: bool,
) -> StoreResult<()> {
let result = sqlx::query(
"update provider_accounts set enabled = $4, updated_at = now()
where id = $1 and provider_instance_id = $2 and provider_kind = $3",
"update provider_accounts set enabled = $3, updated_at = now()
where id = $1 and provider_kind = $2",
)
.bind(account_id)
.bind(&scope.provider_instance_id)
.bind(&scope.provider_kind)
.bind(enabled)
.execute(&mut **transaction)
@@ -2000,12 +1980,10 @@ async fn delete_provider_accounts_in_transaction(
) -> StoreResult<()> {
let deleted = sqlx::query_scalar::<_, String>(
"delete from provider_accounts
where id = any($1::text[]) and provider_instance_id = $2
and provider_kind = $3
where id = any($1::text[]) and provider_kind = $2
returning id",
)
.bind(account_ids)
.bind(&scope.provider_instance_id)
.bind(&scope.provider_kind)
.fetch_all(&mut **transaction)
.await
@@ -2098,7 +2076,6 @@ impl ProviderAccountStore for PgProviderAccountRepository {
.map_err(core_store_error)?;
self.insert_provider_account(NewProviderAccount {
id: account.account.id().as_str().to_owned(),
provider_instance_id: account.account.instance().as_str().to_owned(),
provider_kind: account.account.provider().as_str().to_owned(),
name: account.account.name().to_owned(),
email: account.account.email().map(str::to_owned),
@@ -2141,11 +2118,11 @@ impl ProviderAccountStore for PgProviderAccountRepository {
.collect()
}
async fn list_for_instance(
async fn list_for_provider(
&self,
instance: &ProviderInstanceId,
provider: &ProviderKind,
) -> Result<Vec<CoreProviderAccount>, CoreStoreError> {
self.list_provider_accounts(Some(instance.as_str()), false)
self.list_provider_accounts(Some(provider.as_str()), false)
.await
.map_err(core_store_error)?
.into_iter()
@@ -2365,22 +2342,20 @@ impl ProviderAccountStore for PgProviderAccountRepository {
}
}
const ACCOUNT_SELECT: &str =
"select id, provider_instance_id, provider_kind, name, email, upstream_user_id,
const ACCOUNT_SELECT: &str = "select id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability,
availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
from provider_accounts where id = $1";
const ACCOUNT_SELECT_BY_IDS: &str =
"select id, provider_instance_id, provider_kind, name, email, upstream_user_id,
const ACCOUNT_SELECT_BY_IDS: &str = "select id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability,
availability_reason, cooldown_until, provider_quota_json,
availability_observed_at, quota_observed_at, created_at, updated_at
from provider_accounts
where id = any($1::text[]) and provider_instance_id = $2 and provider_kind = $3
where id = any($1::text[]) and provider_kind = $2
order by id";
fn account_record_from_row(row: sqlx::postgres::PgRow) -> StoreResult<ProviderAccountRecord> {
@@ -2407,15 +2382,12 @@ fn core_account_from_summary(
) -> Result<CoreProviderAccount, CoreStoreError> {
let id = CoreProviderAccountId::new(summary.id)
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let instance = ProviderInstanceId::new(summary.provider_instance_id)
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let provider = ProviderKind::new(summary.provider_kind)
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
let revision = CoreCredentialRevision::new(summary.credential_revision.get())
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
Ok(CoreProviderAccount::new(
id,
instance,
provider,
summary.name,
summary.upstream_user_id,
@@ -2490,7 +2462,6 @@ fn account_summary_from_row(row: sqlx::postgres::PgRow) -> StoreResult<ProviderA
.map_err(|_| invalid("invalid availability"))?;
Ok(ProviderAccountSummary {
id: get(&row, "id")?,
provider_instance_id: get(&row, "provider_instance_id")?,
provider_kind: get(&row, "provider_kind")?,
name: get(&row, "name")?,
email: get(&row, "email")?,
@@ -7,18 +7,15 @@ use chrono::{DateTime, Utc};
use gateway_core::routing::{
ConfigRevision,
snapshot::{
SnapshotClientPolicyFacts, SnapshotFacts, SnapshotProviderInstanceFacts,
SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort,
SnapshotClientPolicyFacts, SnapshotFacts, SnapshotSettingsFacts, SnapshotStoreError,
SnapshotStorePort,
},
};
use sqlx::{PgPool, Postgres, Row, Transaction};
use sqlx::{PgPool, Postgres, Transaction};
use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
use super::{
ClientApiKeySnapshot, ProviderAccountAvailability, ProviderAccountSummary,
ProviderInstanceRecord,
};
use super::ClientApiKeySnapshot;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotRuntimeSettings {
@@ -43,8 +40,6 @@ pub struct RuntimeSnapshotData {
pub config_revision: Revision,
pub observed_current_revision: Revision,
pub settings: SnapshotRuntimeSettings,
pub provider_instances: Vec<ProviderInstanceRecord>,
pub provider_accounts: Vec<ProviderAccountSummary>,
pub client_api_keys: Vec<ClientApiKeySnapshot>,
}
@@ -81,8 +76,6 @@ impl RuntimeSnapshotRepository for PgRuntimeSnapshotRepository {
.map_err(|_| postgres_unavailable("configure runtime snapshot transaction"))?;
let (config_revision, settings) = load_settings(&mut transaction).await?;
let provider_instances = load_instances(&mut transaction).await?;
let provider_accounts = load_accounts(&mut transaction).await?;
let client_api_keys = load_client_keys(&mut transaction).await?;
transaction
.commit()
@@ -95,8 +88,6 @@ impl RuntimeSnapshotRepository for PgRuntimeSnapshotRepository {
config_revision,
observed_current_revision,
settings,
provider_instances,
provider_accounts,
client_api_keys,
})
}
@@ -153,18 +144,6 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
data.settings.rotation_strategy,
data.settings.provider_model_mappings,
);
let provider_instances = data
.provider_instances
.into_iter()
.map(|instance| {
SnapshotProviderInstanceFacts::new(
instance.id,
instance.provider_kind,
instance.base_url,
instance.enabled,
)
})
.collect();
let client_policies = data
.client_api_keys
.into_iter()
@@ -181,7 +160,6 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
config_revision,
observed_current_revision,
settings,
provider_instances,
client_policies,
))
})
@@ -243,84 +221,6 @@ async fn load_settings(
))
}
async fn load_instances(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<Vec<ProviderInstanceRecord>> {
let rows = sqlx::query_as::<
_,
(
String,
String,
String,
String,
bool,
DateTime<Utc>,
DateTime<Utc>,
),
>(
"select id, provider_kind, name, base_url, enabled, created_at, updated_at
from provider_instances where enabled order by provider_kind, name, id",
)
.fetch_all(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("load snapshot provider instances"))?;
Ok(rows
.into_iter()
.map(|row| ProviderInstanceRecord {
id: row.0,
provider_kind: row.1,
name: row.2,
base_url: row.3,
enabled: row.4,
created_at: row.5,
updated_at: row.6,
})
.collect())
}
async fn load_accounts(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<Vec<ProviderAccountSummary>> {
let rows = sqlx::query(
"select id, provider_instance_id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, credential_revision, has_refresh_token,
access_token_expires_at, next_refresh_at, enabled, availability,
availability_reason, cooldown_until, availability_observed_at,
quota_observed_at, created_at, updated_at
from provider_accounts where enabled order by provider_kind, provider_instance_id, id",
)
.fetch_all(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("load snapshot provider accounts"))?;
rows.into_iter()
.map(|row| {
let availability: String = snapshot_get(&row, "availability")?;
Ok(ProviderAccountSummary {
id: snapshot_get(&row, "id")?,
provider_instance_id: snapshot_get(&row, "provider_instance_id")?,
provider_kind: snapshot_get(&row, "provider_kind")?,
name: snapshot_get(&row, "name")?,
email: snapshot_get(&row, "email")?,
upstream_user_id: snapshot_get(&row, "upstream_user_id")?,
upstream_account_id: snapshot_get(&row, "upstream_account_id")?,
plan_type: snapshot_get(&row, "plan_type")?,
credential_revision: revision_from_i64(snapshot_get(&row, "credential_revision")?)?,
has_refresh_token: snapshot_get(&row, "has_refresh_token")?,
access_token_expires_at: snapshot_get(&row, "access_token_expires_at")?,
next_refresh_at: snapshot_get(&row, "next_refresh_at")?,
enabled: snapshot_get(&row, "enabled")?,
availability: parse_availability(&availability)?,
availability_reason: snapshot_get(&row, "availability_reason")?,
cooldown_until: snapshot_get(&row, "cooldown_until")?,
availability_observed_at: snapshot_get(&row, "availability_observed_at")?,
quota_observed_at: snapshot_get(&row, "quota_observed_at")?,
created_at: snapshot_get(&row, "created_at")?,
updated_at: snapshot_get(&row, "updated_at")?,
})
})
.collect()
}
async fn load_client_keys(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<Vec<ClientApiKeySnapshot>> {
@@ -336,26 +236,6 @@ async fn load_client_keys(
.collect()
}
fn parse_availability(value: &str) -> StoreResult<ProviderAccountAvailability> {
match value {
"unknown" => Ok(ProviderAccountAvailability::Unknown),
"ready" => Ok(ProviderAccountAvailability::Ready),
"cooldown" => Ok(ProviderAccountAvailability::Cooldown),
"quota_exhausted" => Ok(ProviderAccountAvailability::QuotaExhausted),
"expired" => Ok(ProviderAccountAvailability::Expired),
"banned" => Ok(ProviderAccountAvailability::Banned),
"invalid" => Ok(ProviderAccountAvailability::Invalid),
_ => Err(invalid("unknown provider account availability")),
}
}
fn snapshot_get<'r, T>(row: &'r sqlx::postgres::PgRow, column: &'static str) -> StoreResult<T>
where
T: sqlx::Decode<'r, sqlx::Postgres> + sqlx::Type<sqlx::Postgres>,
{
row.try_get(column).map_err(|_| invalid(column))
}
fn revision_from_i64(value: i64) -> StoreResult<Revision> {
Revision::new(to_u64(value)?)
}
@@ -15,7 +15,7 @@ use gateway_core::{
provider_ports::{
ProviderCooldown, ProviderCooldownPort, ProviderStoreError, ProviderStoreErrorKind,
},
routing::ProviderInstanceId,
routing::ProviderKind,
};
use redis::{Script, aio::ConnectionManager};
@@ -331,11 +331,11 @@ impl ProviderAccountStore for CooldownCachingProviderAccountStore {
self.authoritative.list_accounts().await
}
async fn list_for_instance(
async fn list_for_provider(
&self,
instance: &ProviderInstanceId,
provider: &ProviderKind,
) -> Result<Vec<ProviderAccount>, CoreStoreError> {
self.authoritative.list_for_instance(instance).await
self.authoritative.list_for_provider(provider).await
}
async fn load_credential(
@@ -1,4 +1,4 @@
//! Provider instance、账号与 OAuth refresh 的 Redis lease/fencing。
//! Provider、账号与 OAuth refresh 的 Redis lease/fencing。
use std::collections::BTreeMap;
use std::fmt;
@@ -14,7 +14,7 @@ use gateway_core::provider_ports::{
ProviderRefreshCapacityRequest, ProviderSchedulingLeaseRequest, ProviderSchedulingState,
ProviderStoreError, ProviderStoreErrorKind,
};
use gateway_core::routing::ProviderInstanceId;
use gateway_core::routing::ProviderKind;
use redis::{Script, aio::ConnectionManager};
use uuid::Uuid;
@@ -125,7 +125,7 @@ return tostring(cursor - 1)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialLeaseScope {
ProviderInstance,
Provider,
ProviderAccount,
OAuthRefreshCapacity,
OAuthRefresh,
@@ -135,7 +135,7 @@ pub enum CredentialLeaseScope {
impl CredentialLeaseScope {
const fn as_str(self) -> &'static str {
match self {
Self::ProviderInstance => "instance",
Self::Provider => "provider",
Self::ProviderAccount => "account",
Self::OAuthRefreshCapacity => "refresh-capacity",
Self::OAuthRefresh => "refresh",
@@ -329,14 +329,10 @@ impl RedisCredentialLeaseRepository {
}))
}
/// 原子推进跨进程共享的 Provider instance 调度游标。
pub async fn advance_scheduling_cursor(&self, provider_instance_id: &str) -> StoreResult<u64> {
require_nonempty(
"provider scheduling cursor",
"provider_instance_id",
provider_instance_id,
)?;
let key = self.scheduling_cursor_key(provider_instance_id)?;
/// 原子推进跨进程共享的 Provider 调度游标。
pub async fn advance_scheduling_cursor(&self, provider_kind: &str) -> StoreResult<u64> {
require_nonempty("provider scheduling cursor", "provider_kind", provider_kind)?;
let key = self.scheduling_cursor_key(provider_kind)?;
let mut connection = self.connection.clone();
let cursor = Script::new(ADVANCE_SCHEDULING_CURSOR_SCRIPT)
.key(key)
@@ -373,10 +369,10 @@ impl RedisCredentialLeaseRepository {
))
}
fn scheduling_cursor_key(&self, provider_instance_id: &str) -> StoreResult<String> {
let fingerprint = resource_fingerprint("provider scheduling cursor", provider_instance_id)?;
fn scheduling_cursor_key(&self, provider_kind: &str) -> StoreResult<String> {
let fingerprint = resource_fingerprint("provider scheduling cursor", provider_kind)?;
Ok(format!(
"{}:scheduler:instance:{{{fingerprint}}}:cursor",
"{}:scheduler:provider:{{{fingerprint}}}:cursor",
self.namespace
))
}
@@ -479,10 +475,10 @@ impl RedisProviderLeaseCoordinator {
async fn next_scheduling_cursor(
&self,
provider_instance_id: &ProviderInstanceId,
provider_kind: &ProviderKind,
) -> Result<u64, ProviderStoreError> {
self.repository
.advance_scheduling_cursor(provider_instance_id.as_str())
.advance_scheduling_cursor(provider_kind.as_str())
.await
.map_err(|_| provider_unavailable("advance scheduling cursor"))
}
@@ -590,12 +586,12 @@ impl RedisProviderLeaseCoordinator {
impl ProviderLeasePort for RedisProviderLeaseCoordinator {
fn load_state<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
accounts: &'a [ProviderAccountId],
) -> futures::future::BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
Box::pin(async move {
let signals = self.load_signals(accounts).await?;
let round_robin_cursor = self.next_scheduling_cursor(provider_instance_id).await?;
let round_robin_cursor = self.next_scheduling_cursor(provider_kind).await?;
Ok(ProviderSchedulingState::new(
signals,
None,
@@ -1,4 +1,4 @@
//! Provider instance 的可重建 Redis circuit。
//! Provider 的可重建 Redis circuit。
use std::num::NonZeroU32;
use std::time::Duration;
@@ -8,7 +8,7 @@ use chrono::{DateTime, Utc};
use gateway_core::engine::execution::{
ProviderCircuitDecision as CoreCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
};
use gateway_core::routing::ProviderInstanceId;
use gateway_core::routing::ProviderKind;
use redis::{Script, aio::ConnectionManager};
use crate::{StoreError, StoreResult, redis_unavailable, require_nonempty};
@@ -68,13 +68,13 @@ pub struct ProviderCircuitObservation {
pub trait ProviderCircuitRepository: Send + Sync {
async fn provider_circuit_decision(
&self,
provider_instance_id: &str,
provider_kind: &str,
) -> StoreResult<ProviderCircuitDecision>;
async fn observe_provider_failure(
&self,
provider_instance_id: &str,
provider_kind: &str,
) -> StoreResult<ProviderCircuitObservation>;
async fn observe_provider_success(&self, provider_instance_id: &str) -> StoreResult<()>;
async fn observe_provider_success(&self, provider_kind: &str) -> StoreResult<()>;
}
#[derive(Clone)]
@@ -100,9 +100,9 @@ impl RedisProviderCircuitRepository {
})
}
fn key(&self, provider_instance_id: &str) -> StoreResult<String> {
let fingerprint = resource_fingerprint("provider circuit", provider_instance_id)?;
Ok(format!("{}:instance:{fingerprint}:circuit", self.namespace))
fn key(&self, provider_kind: &str) -> StoreResult<String> {
let fingerprint = resource_fingerprint("provider circuit", provider_kind)?;
Ok(format!("{}:provider:{fingerprint}:circuit", self.namespace))
}
}
@@ -110,16 +110,12 @@ impl RedisProviderCircuitRepository {
impl ProviderCircuitRepository for RedisProviderCircuitRepository {
async fn provider_circuit_decision(
&self,
provider_instance_id: &str,
provider_kind: &str,
) -> StoreResult<ProviderCircuitDecision> {
require_nonempty(
"provider circuit",
"provider_instance_id",
provider_instance_id,
)?;
require_nonempty("provider circuit", "provider_kind", provider_kind)?;
let mut connection = self.connection.clone();
let (allow, until): (i64, String) = Script::new(DECISION_SCRIPT)
.key(self.key(provider_instance_id)?)
.key(self.key(provider_kind)?)
.invoke_async(&mut connection)
.await
.map_err(|_| redis_unavailable("read provider circuit"))?;
@@ -132,18 +128,14 @@ impl ProviderCircuitRepository for RedisProviderCircuitRepository {
async fn observe_provider_failure(
&self,
provider_instance_id: &str,
provider_kind: &str,
) -> StoreResult<ProviderCircuitObservation> {
require_nonempty(
"provider circuit",
"provider_instance_id",
provider_instance_id,
)?;
require_nonempty("provider circuit", "provider_kind", provider_kind)?;
let duration_ms = u64::try_from(self.policy.open_duration.as_millis())
.map_err(|_| invalid("open duration is too large"))?;
let mut connection = self.connection.clone();
let (failures, until): (String, String) = Script::new(FAILURE_SCRIPT)
.key(self.key(provider_instance_id)?)
.key(self.key(provider_kind)?)
.arg(self.policy.failure_threshold.get())
.arg(duration_ms)
.invoke_async(&mut connection)
@@ -163,15 +155,11 @@ impl ProviderCircuitRepository for RedisProviderCircuitRepository {
})
}
async fn observe_provider_success(&self, provider_instance_id: &str) -> StoreResult<()> {
require_nonempty(
"provider circuit",
"provider_instance_id",
provider_instance_id,
)?;
async fn observe_provider_success(&self, provider_kind: &str) -> StoreResult<()> {
require_nonempty("provider circuit", "provider_kind", provider_kind)?;
let mut connection = self.connection.clone();
redis::cmd("DEL")
.arg(self.key(provider_instance_id)?)
.arg(self.key(provider_kind)?)
.query_async::<i64>(&mut connection)
.await
.map_err(|_| redis_unavailable("reset provider circuit"))?;
@@ -182,10 +170,10 @@ impl ProviderCircuitRepository for RedisProviderCircuitRepository {
impl ProviderCircuitPort for RedisProviderCircuitRepository {
fn decision<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
) -> futures::future::BoxFuture<'a, Result<CoreCircuitDecision, ProviderCircuitError>> {
Box::pin(async move {
self.provider_circuit_decision(provider_instance_id.as_str())
self.provider_circuit_decision(provider_kind.as_str())
.await
.map(|decision| match decision {
ProviderCircuitDecision::Allow => CoreCircuitDecision::Allow,
@@ -199,10 +187,10 @@ impl ProviderCircuitPort for RedisProviderCircuitRepository {
fn observe_failure<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
) -> futures::future::BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async move {
self.observe_provider_failure(provider_instance_id.as_str())
self.observe_provider_failure(provider_kind.as_str())
.await
.map(|_| ())
.map_err(|_| ProviderCircuitError)
@@ -211,10 +199,10 @@ impl ProviderCircuitPort for RedisProviderCircuitRepository {
fn observe_success<'a>(
&'a self,
provider_instance_id: &'a ProviderInstanceId,
provider_kind: &'a ProviderKind,
) -> futures::future::BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async move {
self.observe_provider_success(provider_instance_id.as_str())
self.observe_provider_success(provider_kind.as_str())
.await
.map_err(|_| ProviderCircuitError)
})
@@ -1,12 +0,0 @@
use gateway_store::postgres::NewProviderInstance;
#[test]
fn provider_instance_rejects_unstable_kind() {
let instance = NewProviderInstance {
id: "instance-1".to_owned(),
provider_kind: "xAI!".to_owned(),
name: "xAI".to_owned(),
base_url: "https://example.invalid".to_owned(),
};
assert!(instance.validate().is_err());
}
@@ -12,7 +12,6 @@ fn history_contains_no_payload_field() {
client_api_key_ref: "key-1".to_owned(),
requested_model_id: "coding".to_owned(),
provider_kind: None,
provider_instance_id: None,
provider_account_ref: None,
upstream_model_id: None,
outcome: "failed".to_owned(),
@@ -142,24 +141,14 @@ async fn native_pin_requires_committed_success_but_not_a_specific_transport() {
}
async fn seed_native_history(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> {
sqlx::query(
"insert into provider_instances (
id, provider_kind, name, base_url, enabled, created_at, updated_at
) values (
'inst_history', 'openai', 'history', 'https://example.invalid',
true, now(), now()
)",
)
.execute(pool)
.await?;
sqlx::query(
"insert into provider_accounts (
id, provider_instance_id, provider_kind, name, upstream_user_id,
id, provider_kind, name, upstream_user_id,
provider_credentials_json, credential_revision, has_refresh_token,
access_token_expires_at, enabled, availability, availability_observed_at,
created_at, updated_at
) values (
'acct_history', 'inst_history', 'openai', 'history', 'user-history',
'acct_history', 'openai', 'history', 'user-history',
'{}'::jsonb, 1, false, now() + interval '1 day', true, 'ready',
now(), now(), now()
)",
@@ -170,14 +159,14 @@ async fn seed_native_history(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> {
"insert into model_requests (
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
client_transport, requested_model_id, input_token_estimate,
provider_instance_id, provider_kind, provider_account_id,
provider_kind, provider_account_id,
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
upstream_send_state, downstream_committed_at, outcome, client_status_code,
upstream_status_code, client_response_id, upstream_response_id,
cost_source, started_at, deadline_at, completed_at
) values (
'req_history', 'key_owner', 1, 'openai', 'responses', '/v1/responses',
'http_sse', 'history-model', 1, 'inst_history', 'openai', 'acct_history',
'http_sse', 'history-model', 1, 'openai', 'acct_history',
'acct_history', 'history-upstream',
'websocket', 1, 'sent', now(), 'succeeded', 200, 200,
'resp_gateway_visible', 'resp_upstream_native', 'unavailable',
@@ -10,7 +10,6 @@ use uuid::Uuid;
mod admin_security_audit;
mod admission_recovery;
mod client_keys;
mod config_catalog;
mod execution;
mod history;
mod observability;
@@ -517,25 +517,14 @@ async fn seed_observability_facts(
pool: &PgPool,
now: chrono::DateTime<Utc>,
) -> Result<(), sqlx::Error> {
sqlx::query(
"insert into provider_instances (
id, provider_kind, name, base_url, enabled, created_at, updated_at
) values (
'inst_observe', 'openai', 'observability', 'https://example.invalid',
true, $1, $1
)",
)
.bind(now)
.execute(pool)
.await?;
sqlx::query(
"insert into provider_accounts (
id, provider_instance_id, provider_kind, name, email, upstream_user_id,
id, provider_kind, name, email, upstream_user_id,
upstream_account_id, plan_type, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, enabled, availability,
availability_observed_at, created_at, updated_at
) values (
'acct_observe', 'inst_observe', 'openai', 'primary', 'account@example.invalid',
'acct_observe', 'openai', 'primary', 'account@example.invalid',
'user-observe', null, 'pro', '{}'::jsonb, 1, false, $1 + interval '1 day',
true, 'ready', $1, $1, $1
)",
@@ -547,7 +536,7 @@ async fn seed_observability_facts(
"insert into model_requests (
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
client_transport, requested_model_id, input_token_estimate,
provider_instance_id, provider_kind, provider_account_id,
provider_kind, provider_account_id,
provider_account_ref, upstream_model_id, upstream_transport, websocket_pool,
attempt_count,
upstream_send_state, downstream_committed_at, outcome, client_status_code,
@@ -559,7 +548,7 @@ async fn seed_observability_facts(
started_at, deadline_at, completed_at
) values (
'req_observe_success', 'key_observe', 1, 'openai', 'responses', '/v1/responses',
'http_sse', 'public-model', 100, 'inst_observe', 'openai', 'acct_observe',
'http_sse', 'public-model', 100, 'openai', 'acct_observe',
'acct_observe', 'upstream-model',
'http_sse', 'reuse', 1, 'sent', $1 - interval '19 minutes', 'succeeded', 200, 200,
'resp_observe_success', 'upstream_req_success', 'upstream_resp_success',
@@ -575,7 +564,7 @@ async fn seed_observability_facts(
"insert into model_requests (
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
client_transport, requested_model_id, input_token_estimate,
provider_instance_id, provider_kind, provider_account_id,
provider_kind, provider_account_id,
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
upstream_send_state, outcome, client_status_code, upstream_status_code,
error_kind, provider_error_code, error_message, retry_after_ms,
@@ -584,7 +573,7 @@ async fn seed_observability_facts(
started_at, deadline_at, completed_at
) values (
'req_observe_failed', 'key_observe', 1, 'openai', 'responses', '/v1/responses',
'http_sse', 'public-model', 80, 'inst_observe', 'openai', 'acct_observe',
'http_sse', 'public-model', 80, 'openai', 'acct_observe',
'acct_observe', 'upstream-model',
'http_sse', 2, 'sent', 'failed', 502, 429, 'rate_limited', 'rate_limit',
'upstream limited', 1000, 0, 0, true, false, 'unavailable', 700,
@@ -597,12 +586,12 @@ async fn seed_observability_facts(
sqlx::query(
"insert into ops_events (
id, model_request_id, attempt_index, level, component, operation,
provider_instance_id, provider_kind, provider_account_id,
provider_kind, provider_account_id,
provider_account_ref, upstream_model_id, failure_kind, status_code,
provider_error_code, retry_after_ms, latency_ms, message, occurrence_count, created_at
) values (
'ops_observe_retry', 'req_observe_failed', 1, 'warning', 'routing', 'responses',
'inst_observe', 'openai', 'acct_observe', 'acct_observe',
'openai', 'acct_observe', 'acct_observe',
'upstream-model', 'rate_limited', 429, 'rate_limit', 1000, 300,
'first account was limited', 1, $1 - interval '9 minutes 30 seconds'
)",
@@ -10,7 +10,6 @@ fn request_scoped_ops_event_requires_attempt_index() {
level: OpsEventLevel::Warning,
component: "routing".to_owned(),
operation: "fallback".to_owned(),
provider_instance_id: None,
provider_kind: None,
provider_account_id: None,
provider_account_ref: None,
@@ -5,7 +5,7 @@ use gateway_admin::{
model::{
MutationActor, MutationContext, PageSize, Revision as AdminRevision,
accounts::{
AccountListQuery, AccountSort, AccountSortField, AccountStatus, DeleteAccount,
AccountListQuery, AccountSort, AccountSortField, AccountStatus, DeleteAccounts,
SetAccountEnabled as AdminSetAccountEnabled, SortDirection,
},
observability::TimeRange,
@@ -52,9 +52,6 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
let Some(database) = TestDatabase::create("provider_account_quota_batch").await else {
return;
};
seed_instance(&database.pool, "inst_openai_admin", "openai")
.await
.expect("seed OpenAI instance");
let repository = PgProviderAccountRepository::new(database.pool.clone());
for id in ["acct_quota_a", "acct_quota_b", "acct_quota_empty"] {
repository
@@ -117,19 +114,12 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
let Some(database) = TestDatabase::create("provider_account_terminal_list").await else {
return;
};
seed_instance(&database.pool, "inst_openai_admin", "openai")
.await
.expect("seed OpenAI instance");
seed_instance(&database.pool, "inst_xai_admin", "xai")
.await
.expect("seed xAI instance");
let repository = PgProviderAccountRepository::new(database.pool.clone());
let now = Utc::now();
let mut alpha = account("acct_alpha", "user-alpha");
alpha.email = Some("alpha@example.invalid".to_owned());
let mut beta = account("acct_beta", "user-beta");
beta.provider_instance_id = "inst_xai_admin".to_owned();
beta.provider_kind = "xai".to_owned();
beta.email = Some("beta@example.invalid".to_owned());
beta.availability = ProviderAccountAvailability::Banned;
@@ -150,7 +140,6 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
ModelRequestSeed {
request_id: "req_alpha_recent",
account_id: "acct_alpha",
provider_instance_id: "inst_openai_admin",
provider_kind: "openai",
model: "gpt-list",
total_tokens: 10,
@@ -165,7 +154,6 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
ModelRequestSeed {
request_id: "req_beta_recent",
account_id: "acct_beta",
provider_instance_id: "inst_xai_admin",
provider_kind: "xai",
model: "grok-list",
total_tokens: 50,
@@ -180,7 +168,6 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
ModelRequestSeed {
request_id: "req_beta_expired_retention",
account_id: "acct_beta",
provider_instance_id: "inst_xai_admin",
provider_kind: "xai",
model: "grok-list",
total_tokens: 500,
@@ -195,7 +182,6 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
ModelRequestSeed {
request_id: "req_charlie_recent",
account_id: "acct_charlie",
provider_instance_id: "inst_openai_admin",
provider_kind: "openai",
model: "gpt-list",
total_tokens: 60,
@@ -308,16 +294,12 @@ async fn terminal_credential_list_preserves_grouped_filters_and_unpaged_collecti
let Some(database) = TestDatabase::create("provider_credential_terminal_windows").await else {
return;
};
seed_instance(&database.pool, "inst_xai_admin", "xai")
.await
.expect("seed xAI instance");
let repository = PgProviderAccountRepository::new(database.pool.clone());
for index in 0..205_u16 {
let mut credential = account(
&format!("acct_xai_{index:03}"),
&format!("user-xai-{index:03}"),
);
credential.provider_instance_id = "inst_xai_admin".to_owned();
credential.provider_kind = "xai".to_owned();
credential.availability = match index {
0 => ProviderAccountAvailability::Expired,
@@ -337,7 +319,6 @@ async fn terminal_credential_list_preserves_grouped_filters_and_unpaged_collecti
.list_credentials(
&provider,
CredentialListQuery {
provider_instance_id: None,
availability: None,
enabled: None,
window: CredentialListWindow::All,
@@ -352,7 +333,6 @@ async fn terminal_credential_list_preserves_grouped_filters_and_unpaged_collecti
.list_credentials(
&provider,
CredentialListQuery {
provider_instance_id: None,
availability: None,
enabled: None,
window: CredentialListWindow::Page {
@@ -370,7 +350,6 @@ async fn terminal_credential_list_preserves_grouped_filters_and_unpaged_collecti
.list_credentials(
&provider,
CredentialListQuery {
provider_instance_id: None,
availability: Some(CredentialAvailabilityFilter::AnyOf(vec![
gateway_admin::model::accounts::AccountAvailability::Expired,
gateway_admin::model::accounts::AccountAvailability::Banned,
@@ -392,9 +371,6 @@ async fn terminal_admin_usage_chunks_large_selections_and_preserves_exact_costs(
let Some(database) = TestDatabase::create("provider_account_terminal_usage").await else {
return;
};
seed_instance(&database.pool, "inst_openai_admin", "openai")
.await
.expect("seed OpenAI instance");
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(account("acct_usage_exact", "user-usage-exact"))
@@ -406,7 +382,6 @@ async fn terminal_admin_usage_chunks_large_selections_and_preserves_exact_costs(
ModelRequestSeed {
request_id: "req_usage_exact",
account_id: "acct_usage_exact",
provider_instance_id: "inst_openai_admin",
provider_kind: "openai",
model: "gpt-exact",
total_tokens: 18,
@@ -448,9 +423,6 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
let Some(database) = TestDatabase::create("provider_account_terminal_mutation").await else {
return;
};
seed_instance(&database.pool, "inst_openai_admin", "openai")
.await
.expect("seed OpenAI instance");
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(account("acct_terminal_mutation", "user-terminal-mutation"))
.await
@@ -482,10 +454,10 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
assert!(!enabled);
let error = store
.delete_account(
DeleteAccount {
.delete_accounts(
DeleteAccounts {
expected_config_revision: AdminRevision::new(1).expect("stale revision"),
account_id: "acct_terminal_mutation".to_owned(),
account_ids: vec!["acct_terminal_mutation".to_owned()],
},
&context,
)
@@ -504,10 +476,10 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
assert_eq!(audit_count, 1);
let revision = store
.delete_account(
DeleteAccount {
.delete_accounts(
DeleteAccounts {
expected_config_revision: revision,
account_id: "acct_terminal_mutation".to_owned(),
account_ids: vec!["acct_terminal_mutation".to_owned()],
},
&context,
)
@@ -537,23 +509,29 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
}
#[tokio::test]
async fn terminal_admin_delete_removes_an_enabled_account_atomically() {
async fn terminal_admin_delete_removes_enabled_accounts_in_one_transaction() {
let Some(database) = TestDatabase::create("provider_account_enabled_delete").await else {
return;
};
seed_instance(&database.pool, "inst_openai_admin", "openai")
.await
.expect("seed OpenAI instance");
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(account("acct_enabled_delete", "user-enabled-delete"))
.await
.expect("insert enabled account");
let repository = PgProviderAccountRepository::new(database.pool.clone());
for (account_id, upstream_user_id) in [
("acct_enabled_delete_a", "user-enabled-delete-a"),
("acct_enabled_delete_b", "user-enabled-delete-b"),
] {
repository
.insert_provider_account(account(account_id, upstream_user_id))
.await
.expect("insert enabled account");
}
let revision = PgAdminAccountStore::new(database.pool.clone())
.delete_account(
DeleteAccount {
.delete_accounts(
DeleteAccounts {
expected_config_revision: AdminRevision::new(1).expect("initial revision"),
account_id: "acct_enabled_delete".to_owned(),
account_ids: vec![
"acct_enabled_delete_a".to_owned(),
"acct_enabled_delete_b".to_owned(),
],
},
&MutationContext {
actor: MutationActor::System,
@@ -565,7 +543,8 @@ async fn terminal_admin_delete_removes_an_enabled_account_atomically() {
assert_eq!(revision.get(), 2);
assert_eq!(
account_count(&database.pool, "acct_enabled_delete").await,
account_count(&database.pool, "acct_enabled_delete_a").await
+ account_count(&database.pool, "acct_enabled_delete_b").await,
0
);
database.close().await;
@@ -576,13 +555,9 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
let Some(database) = TestDatabase::create("provider_account_admin_upsert").await else {
return;
};
seed_instance(&database.pool, "inst_openai_admin", "openai")
.await
.expect("seed Codex instance");
let repository = PgProviderAccountRepository::new(database.pool.clone());
let scope = ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
provider_instance_id: "inst_openai_admin".to_owned(),
};
let imported = repository
.import_provider_accounts(
@@ -671,14 +646,10 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
let Some(database) = TestDatabase::create("provider_account_core_refresh").await else {
return;
};
seed_instance(&database.pool, "inst_xai_refresh", "xai")
.await
.expect("seed xAI instance");
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(NewProviderAccount {
id: "acct_core_refresh".to_owned(),
provider_instance_id: "inst_xai_refresh".to_owned(),
provider_kind: "xai".to_owned(),
name: "before refresh".to_owned(),
email: Some("before@example.invalid".to_owned()),
@@ -789,16 +760,9 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
let Some(database) = TestDatabase::create("provider_account_admin").await else {
return;
};
seed_instance(&database.pool, "inst_openai_admin", "openai")
.await
.expect("seed Codex instance");
seed_instance(&database.pool, "inst_xai_admin", "xai")
.await
.expect("seed xAI instance");
let repository = PgProviderAccountRepository::new(database.pool.clone());
let scope = ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
provider_instance_id: "inst_openai_admin".to_owned(),
};
let mut cooldown_account = account("acct_admin_b", "user-admin-b");
@@ -862,7 +826,6 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
let wrong_scope = ProviderAccountAdminScope {
provider_kind: "xai".to_owned(),
provider_instance_id: "inst_xai_admin".to_owned(),
};
let wrong_scope_error = repository
.rotate_provider_account(
@@ -949,7 +912,6 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
NewProviderAccount {
id: id.to_owned(),
provider_instance_id: "inst_openai_admin".to_owned(),
provider_kind: "openai".to_owned(),
name: id.to_owned(),
email: Some(format!("{id}@example.invalid")),
@@ -1024,7 +986,6 @@ fn audit(id: &str, action: &str, entity_ref: &str) -> AdminAuditEvent {
struct ModelRequestSeed<'a> {
request_id: &'a str,
account_id: &'a str,
provider_instance_id: &'a str,
provider_kind: &'a str,
model: &'a str,
total_tokens: i64,
@@ -1040,7 +1001,7 @@ async fn seed_model_request(
"insert into model_requests (
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
client_transport, requested_model_id, input_token_estimate,
provider_instance_id, provider_kind, provider_account_id,
provider_kind, provider_account_id,
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
upstream_send_state, outcome, client_status_code, upstream_status_code,
input_tokens, output_tokens, cached_tokens, cache_write_tokens, reasoning_tokens,
@@ -1048,15 +1009,14 @@ async fn seed_model_request(
started_at, deadline_at, completed_at
) values (
$1, 'key-provider-account-test', 1, 'openai', 'responses', '/v1/responses',
'http_sse', $5, 1, $3, $4, $2, $2, $5, 'http_sse', 1,
'sent', 'succeeded', 200, 200, $6, 0, 0, 0, 0,
$6, 'provider_reported', $7::numeric, 'USD', $8,
$8 + interval '5 minutes', $8 + interval '1 second'
'http_sse', $4, 1, $3, $2, $2, $4, 'http_sse', 1,
'sent', 'succeeded', 200, 200, $5, 0, 0, 0, 0,
$5, 'provider_reported', $6::numeric, 'USD', $7,
$7 + interval '5 minutes', $7 + interval '1 second'
)",
)
.bind(seed.request_id)
.bind(seed.account_id)
.bind(seed.provider_instance_id)
.bind(seed.provider_kind)
.bind(seed.model)
.bind(seed.total_tokens)
@@ -1067,23 +1027,6 @@ async fn seed_model_request(
Ok(())
}
async fn seed_instance(
pool: &sqlx::PgPool,
id: &str,
provider_kind: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"insert into provider_instances (
id, provider_kind, name, base_url, enabled, created_at, updated_at
) values ($1, $2, $1, 'https://example.invalid', true, now(), now())",
)
.bind(id)
.bind(provider_kind)
.execute(pool)
.await?;
Ok(())
}
async fn current_revision(pool: &sqlx::PgPool) -> i64 {
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(pool)
@@ -13,7 +13,7 @@ use gateway_core::{
QuotaObservation, QuotaWriteOutcome,
},
error::{StoreError as CoreStoreError, StoreErrorKind},
routing::{ProviderInstanceId, ProviderKind},
routing::ProviderKind,
};
use gateway_store::{
Revision, StoreBackend, StoreError, StoreResult,
@@ -58,11 +58,11 @@ impl ProviderAccountStore for CooldownTestAccountStore {
Ok(self.accounts.clone())
}
async fn list_for_instance(
async fn list_for_provider(
&self,
_instance: &ProviderInstanceId,
_provider: &ProviderKind,
) -> Result<Vec<ProviderAccount>, CoreStoreError> {
unreachable!("cooldown tests do not list one instance")
unreachable!("cooldown tests do not list one provider")
}
async fn load_credential(
@@ -377,7 +377,6 @@ fn ready_test_account(account_id: &str) -> ProviderAccount {
fn test_provider_account(account_id: &str) -> ProviderAccount {
ProviderAccount::new(
ProviderAccountId::new(account_id).expect("valid account ID"),
ProviderInstanceId::new("inst_cooldown_test").expect("valid instance ID"),
ProviderKind::new("openai").expect("valid provider kind"),
"Cooldown test".to_owned(),
"upstream-user".to_owned(),
+23 -107
View File
@@ -38,12 +38,9 @@ use gateway_core::operation::{
};
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingFlowPort, OAuthPendingPutOutcome,
OAuthPendingTakeOutcome, ProviderInstanceCatalogPort, ProviderRuntimePolicyPort,
ProviderStoreError, ProviderStoreErrorKind,
};
use gateway_core::routing::{
InstanceHealth, ProviderInstance, ProviderInstanceId, ProviderKind, UpstreamModelId,
OAuthPendingTakeOutcome, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
use serde_json::{Map, Number, Value};
@@ -68,7 +65,7 @@ use crate::transport::profile::{
};
const PROVIDER_NAME: &str = "openai";
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 1;
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 2;
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
@@ -77,7 +74,6 @@ pub(crate) struct OpenAiAdminProvider {
provider_kind: ProviderKind,
profile: CodexWireProfileState,
accounts: Arc<dyn ProviderAccountStore>,
instances: Arc<dyn ProviderInstanceCatalogPort>,
credentials: Arc<CodexCredentialAdminService>,
verifier: Arc<dyn CodexAccountIdentityVerifier>,
oauth: Arc<dyn CodexOAuthAdmin>,
@@ -103,7 +99,6 @@ impl OpenAiAdminProvider {
provider_kind: ProviderKind,
profile: CodexWireProfileState,
accounts: Arc<dyn ProviderAccountStore>,
instances: Arc<dyn ProviderInstanceCatalogPort>,
services: OpenAiAdminServices,
websocket_pool: Arc<CodexWebSocketPool>,
desktop_release: CodexDesktopReleaseStatus,
@@ -112,7 +107,6 @@ impl OpenAiAdminProvider {
provider_kind,
profile,
accounts,
instances,
credentials: services.credentials,
verifier: services.verifier,
oauth: services.oauth,
@@ -136,30 +130,6 @@ impl OpenAiAdminProvider {
.ok_or_else(|| provider_admin_error(ProviderAdminErrorKind::NotFound))
}
async fn instance(
&self,
account: &ProviderAccount,
) -> Result<ProviderInstance, ProviderAdminError> {
let instance = self
.instances
.list_instances(&self.provider_kind, true)
.await
.map_err(map_provider_store_error)?
.into_iter()
.find(|instance| instance.id() == account.instance())
.ok_or_else(|| provider_admin_error(ProviderAdminErrorKind::NotFound))?;
if instance.provider_kind() != &self.provider_kind {
return Err(provider_admin_error(ProviderAdminErrorKind::Internal));
}
Ok(ProviderInstance::new(
instance.id().clone(),
instance.provider_kind().clone(),
instance.base_url().to_owned(),
instance.enabled(),
InstanceHealth::Healthy,
))
}
async fn preserve_existing_installation_id(
&self,
mut incoming: NewProviderAccount,
@@ -173,7 +143,6 @@ impl OpenAiAdminProvider {
return Ok(incoming);
};
if existing.provider() != incoming.account.provider()
|| existing.instance() != incoming.account.instance()
|| existing.upstream_user_id() != incoming.account.upstream_user_id()
|| existing.upstream_account_id() != incoming.account.upstream_account_id()
{
@@ -283,10 +252,9 @@ impl ProviderAdmin for OpenAiAdminProvider {
) -> Result<PreparedCredentialImport, ProviderAdminError> {
let prepared = self
.credentials
.prepare_import_document(
command.provider_instance_id.clone(),
Value::Object(command.document.into_provider_data().into_inner()),
)
.prepare_import_document(Value::Object(
command.document.into_provider_data().into_inner(),
))
.await
.map_err(map_credential_admin_error)?;
let observed_at = Utc::now();
@@ -297,7 +265,6 @@ impl ProviderAdmin for OpenAiAdminProvider {
}
Ok(PreparedCredentialImport {
provider_kind: self.provider_kind.clone(),
provider_instance_id: command.provider_instance_id,
credentials,
})
}
@@ -340,21 +307,16 @@ impl ProviderAdmin for OpenAiAdminProvider {
PreparedAuthorizationCredential::Create(prepared_create(credential, Utc::now())?)
}
CompletedCodexOAuthCredential::Reauthorize(credential) => {
let (provider_instance_id, provider_kind) = match completed.mutation.target() {
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
..
} => (
provider_instance_id.clone(),
completed.mutation.provider_kind().clone(),
),
let provider_kind = match completed.mutation.target() {
AuthorizationMutationTarget::Reauthorize { .. } => {
completed.mutation.provider_kind().clone()
}
AuthorizationMutationTarget::Create { .. } => {
return Err(provider_admin_error(ProviderAdminErrorKind::Internal));
}
};
PreparedAuthorizationCredential::Reauthorize(prepared_rotation(
credential,
provider_instance_id,
provider_kind,
)?)
}
@@ -426,11 +388,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
next_refresh_at,
})
.map_err(map_credential_admin_error)?;
prepared_rotation(
prepared,
command.account.provider_instance_id,
command.account.provider_kind,
)
prepared_rotation(prepared, command.account.provider_kind)
}
async fn prepare_refresh(
@@ -447,11 +405,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
.manual_refresh(account_id, revision)
.await
.map_err(map_credential_admin_error)?;
prepared_rotation(
prepared,
command.account.provider_instance_id,
command.account.provider_kind,
)
prepared_rotation(prepared, command.account.provider_kind)
}
async fn quota(
@@ -463,12 +417,11 @@ impl ProviderAdmin for OpenAiAdminProvider {
refresh,
rolling_usage: _,
} = request;
let account = self.account(&account_id).await?;
self.account(&account_id).await?;
let snapshot = if refresh {
let instance = self.instance(&account).await?;
Some(
self.quota
.refresh_account(&instance, &account_id)
.refresh_account(&account_id)
.await
.map_err(map_quota_error)?,
)
@@ -487,21 +440,20 @@ impl ProviderAdmin for OpenAiAdminProvider {
refresh: bool,
) -> Result<ProviderModels, ProviderAdminError> {
let account = self.account(account_id).await?;
let instance = self.instance(&account).await?;
let models = if refresh {
self.catalog
.synchronize_account(&instance, account_id)
.synchronize_account(account_id)
.await
.map_err(map_catalog_error)?
} else {
self.catalog
.cached_account_models(instance.id(), account_id, account.revision())
.cached_account_models(account_id, account.revision())
.map_err(map_catalog_error)?
.unwrap_or_default()
};
let observed_at = self
.catalog
.cached(instance.id())
.cached()
.map_err(map_catalog_error)?
.map(|snapshot| DateTime::<Utc>::from(snapshot.observed_at()));
let models = models
@@ -573,7 +525,6 @@ fn prepared_create(
let availability = admin_availability(account.availability());
Ok(PreparedCredentialCreate {
account_id: account.id().clone(),
provider_instance_id: account.instance().clone(),
provider_kind: account.provider().clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
@@ -594,7 +545,6 @@ fn prepared_create(
fn prepared_rotation(
prepared: crate::credential::PreparedCodexCredentialRotation,
provider_instance_id: ProviderInstanceId,
provider_kind: ProviderKind,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
let (profile, credential, guard) = prepared.into_parts();
@@ -615,7 +565,6 @@ fn prepared_rotation(
Ok(PreparedCredentialRotation::new(
PreparedCredentialRotationFacts {
account_id,
provider_instance_id,
provider_kind,
expected_credential_revision,
name: profile.name,
@@ -678,7 +627,6 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
Ok(ProviderAccount::new(
account_id,
account.provider_instance_id.clone(),
account.provider_kind.clone(),
account.name.clone(),
account.upstream_user_id.clone(),
@@ -742,7 +690,6 @@ fn dashboard_desktop_release(
fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) -> bool {
account.id().as_str() == record.id
&& account.instance() == &record.provider_instance_id
&& account.provider() == &record.provider_kind
&& account.revision().get() == record.credential_revision.get()
&& account.name() == record.name
@@ -981,7 +928,6 @@ struct PendingDocument {
flow_id: String,
owner_ref: String,
started_request_ref: String,
provider_instance_id: String,
name: String,
expires_at: DateTime<Utc>,
state: String,
@@ -1007,11 +953,9 @@ struct PendingMutationDocument {
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum PendingTargetDocument {
Create {
provider_instance_id: String,
name: String,
},
Reauthorize {
provider_instance_id: String,
account_id: String,
expected_credential_revision: u64,
},
@@ -1039,10 +983,6 @@ fn encode_pending(pending: &CodexPendingAuthorization) -> Map<String, Value> {
"started_request_ref".to_owned(),
Value::String(pending.started_request_ref().to_owned()),
);
document.insert(
"provider_instance_id".to_owned(),
Value::String(pending.provider_instance_id().to_owned()),
);
document.insert("name".to_owned(), Value::String(pending.name().to_owned()));
document.insert(
"expires_at".to_owned(),
@@ -1111,27 +1051,15 @@ fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value
fn encode_target(target: &AuthorizationMutationTarget) -> Map<String, Value> {
let mut document = Map::new();
match target {
AuthorizationMutationTarget::Create {
provider_instance_id,
name,
} => {
AuthorizationMutationTarget::Create { name } => {
document.insert("kind".to_owned(), Value::String("create".to_owned()));
document.insert(
"provider_instance_id".to_owned(),
Value::String(provider_instance_id.to_string()),
);
document.insert("name".to_owned(), Value::String(name.clone()));
}
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} => {
document.insert("kind".to_owned(), Value::String("reauthorize".to_owned()));
document.insert(
"provider_instance_id".to_owned(),
Value::String(provider_instance_id.to_string()),
);
document.insert(
"account_id".to_owned(),
Value::String(account_id.to_string()),
@@ -1174,7 +1102,6 @@ fn decode_pending(
flow_id: document.flow_id,
owner_ref: document.owner_ref,
started_request_ref: document.started_request_ref,
provider_instance_id: document.provider_instance_id,
name: document.name,
expires_at: document.expires_at,
state: SecretString::from(document.state),
@@ -1197,21 +1124,11 @@ fn decode_mutation(
let provider_kind = ProviderKind::new(document.provider_kind)
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
let target = match document.target {
PendingTargetDocument::Create {
provider_instance_id,
name,
} => AuthorizationMutationTarget::Create {
provider_instance_id: ProviderInstanceId::new(provider_instance_id)
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?,
name,
},
PendingTargetDocument::Create { name } => AuthorizationMutationTarget::Create { name },
PendingTargetDocument::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} => AuthorizationMutationTarget::Reauthorize {
provider_instance_id: ProviderInstanceId::new(provider_instance_id)
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?,
account_id: ProviderAccountId::new(account_id)
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?,
expected_credential_revision: Revision::new(expected_credential_revision)
@@ -1352,7 +1269,7 @@ fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
use CodexCredentialQuotaError as Error;
provider_admin_error(match error {
Error::InvalidInstance | Error::InvalidCredentialData => ProviderAdminErrorKind::Invalid,
Error::InvalidCredentialData => ProviderAdminErrorKind::Invalid,
Error::NotFound => ProviderAdminErrorKind::NotFound,
Error::RevisionConflict => ProviderAdminErrorKind::Conflict,
Error::Repository(_) | Error::Store | Error::Upstream => {
@@ -1364,10 +1281,9 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
use CodexCredentialCatalogError as Error;
provider_admin_error(match error {
Error::InvalidInstance
| Error::InvalidCredentialData
| Error::ConflictingModelFacts
| Error::InvalidEtag => ProviderAdminErrorKind::Invalid,
Error::InvalidCredentialData | Error::ConflictingModelFacts | Error::InvalidEtag => {
ProviderAdminErrorKind::Invalid
}
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict,
Error::Upstream | Error::Cache => ProviderAdminErrorKind::Unavailable,
@@ -19,7 +19,7 @@ use gateway_core::provider_ports::{
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRefreshPolicy,
ProviderRuntimePolicyPort,
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use gateway_core::routing::ProviderKind;
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
use serde_json::Value;
@@ -65,7 +65,6 @@ const CPR_ACCOUNT_KEYS: &[&str] = &[
pub struct ImportCodexOAuthCredential {
pub account_id: String,
pub provider_instance_id: String,
pub name: String,
pub secret: CodexOAuthSecret,
pub verified_account: CodexAccountProfile,
@@ -78,7 +77,6 @@ impl std::fmt::Debug for ImportCodexOAuthCredential {
formatter
.debug_struct("ImportCodexOAuthCredential")
.field("account_id", &self.account_id)
.field("provider_instance_id", &self.provider_instance_id)
.field("name", &self.name)
.field("secret", &"<redacted>")
.field("verified_account", &self.verified_account)
@@ -346,8 +344,6 @@ impl CodexCredentialAdmin {
) -> Result<NewProviderAccount, CodexCredentialAdminError> {
let account_id = ProviderAccountId::new(input.account_id)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let instance = ProviderInstanceId::new(input.provider_instance_id)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
if input.name.trim().is_empty() {
@@ -363,7 +359,6 @@ impl CodexCredentialAdmin {
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let account = ProviderAccount::new(
account_id,
instance,
provider,
input.name,
upstream_user_id,
@@ -626,7 +621,6 @@ impl CodexCredentialAdminService {
/// 按文档结构严格判别账号布局,并归一到唯一 `NewProviderAccount` 写入路径。
pub async fn prepare_import_document(
&self,
provider_instance_id: ProviderInstanceId,
payload: Value,
) -> Result<PreparedCodexAccountImport, CodexCredentialAdminError> {
if serde_json::to_vec(&payload)
@@ -673,7 +667,6 @@ impl CodexCredentialAdminService {
)?;
let mut prepared = CodexCredentialAdmin.prepare_import(ImportCodexOAuthCredential {
account_id,
provider_instance_id: provider_instance_id.to_string(),
name: candidate
.name
.filter(|name| !name.trim().is_empty())
@@ -9,14 +9,13 @@ use gateway_core::engine::credential::{
AccountAvailability, CredentialRevision, ProviderAccount, ProviderAccountId,
};
use gateway_core::engine::provider::ProviderCatalogGeneration;
use gateway_core::routing::{ProviderInstance, ProviderInstanceId};
use secrecy::ExposeSecret;
use thiserror::Error;
use tokio::sync::Notify;
use uuid::Uuid;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use crate::provider::{CodexOriginPolicy, CodexProviderInstanceConfig};
use crate::provider::OFFICIAL_CODEX_BASE_URL;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{CodexBackendClient, CodexCatalogModel, CodexRequestContext};
@@ -24,7 +23,6 @@ const MAX_RESPONSE_ETAG_BYTES: usize = 256;
#[derive(Clone)]
pub struct CodexCredentialCatalogSnapshot {
provider_instance_id: ProviderInstanceId,
observed_at: SystemTime,
models: Vec<CodexCatalogModel>,
account_models: BTreeMap<ProviderAccountId, CodexAccountEntitlement>,
@@ -37,11 +35,6 @@ struct CodexAccountEntitlement {
}
impl CodexCredentialCatalogSnapshot {
#[must_use]
pub const fn provider_instance_id(&self) -> &ProviderInstanceId {
&self.provider_instance_id
}
#[must_use]
pub const fn observed_at(&self) -> SystemTime {
self.observed_at
@@ -69,7 +62,6 @@ impl fmt::Debug for CodexCredentialCatalogSnapshot {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexCredentialCatalogSnapshot")
.field("provider_instance_id", &self.provider_instance_id)
.field("observed_at", &self.observed_at)
.field("model_count", &self.models.len())
.field("account_count", &self.account_models.len())
@@ -79,8 +71,6 @@ impl fmt::Debug for CodexCredentialCatalogSnapshot {
#[derive(Debug, Error)]
pub enum CodexCredentialCatalogError {
#[error("Codex model catalog instance is invalid")]
InvalidInstance,
#[error("Codex model catalog has no eligible account")]
NoEligibleCredential,
#[error("Codex model catalog account data is invalid")]
@@ -99,16 +89,16 @@ pub enum CodexCredentialCatalogError {
#[derive(Default)]
struct CatalogEtagState {
applied: BTreeMap<ProviderInstanceId, String>,
pending: BTreeMap<ProviderInstanceId, String>,
inflight: BTreeMap<ProviderInstanceId, String>,
applied: Option<String>,
pending: Option<String>,
inflight: Option<String>,
}
#[derive(Default)]
struct CatalogCacheState {
revision: u64,
generation: u64,
snapshots: BTreeMap<ProviderInstanceId, CodexCredentialCatalogSnapshot>,
snapshot: Option<CodexCredentialCatalogSnapshot>,
}
struct FetchedAccountModels {
@@ -116,7 +106,7 @@ struct FetchedAccountModels {
etag: Option<String>,
}
struct FetchedInstanceCatalog {
struct FetchedCatalog {
snapshot: CodexCredentialCatalogSnapshot,
etags: Vec<String>,
}
@@ -132,7 +122,6 @@ pub struct CodexCredentialCatalogService {
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: reqwest::Client,
origin_policy: Arc<dyn CodexOriginPolicy>,
cache: Arc<RwLock<CatalogCacheState>>,
etags: Arc<Mutex<CatalogEtagState>>,
etag_notification: Arc<Notify>,
@@ -143,13 +132,11 @@ impl CodexCredentialCatalogService {
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: reqwest::Client,
origin_policy: Arc<dyn CodexOriginPolicy>,
) -> Self {
Self {
repository,
profile,
http,
origin_policy,
cache: Arc::new(RwLock::new(CatalogCacheState::default())),
etags: Arc::new(Mutex::new(CatalogEtagState::default())),
etag_notification: Arc::new(Notify::new()),
@@ -167,25 +154,23 @@ impl CodexCredentialCatalogService {
pub fn cached(
&self,
instance: &ProviderInstanceId,
) -> Result<Option<CodexCredentialCatalogSnapshot>, CodexCredentialCatalogError> {
Ok(self
.cache
.read()
.map_err(|_| CodexCredentialCatalogError::Cache)?
.snapshots
.get(instance)
.snapshot
.as_ref()
.cloned())
}
/// 读取单账号当前仍新鲜的模型 entitlement;不触发网络。
pub fn cached_account_models(
&self,
instance: &ProviderInstanceId,
account_id: &ProviderAccountId,
revision: CredentialRevision,
) -> Result<Option<Vec<String>>, CodexCredentialCatalogError> {
Ok(self.cached(instance)?.and_then(|snapshot| {
Ok(self.cached()?.and_then(|snapshot| {
snapshot
.account_models(account_id, revision)
.map(<[String]>::to_vec)
@@ -194,12 +179,11 @@ impl CodexCredentialCatalogService {
pub fn observed_model_support(
&self,
instance: &ProviderInstanceId,
account_id: &ProviderAccountId,
revision: CredentialRevision,
model: &str,
) -> Result<Option<bool>, CodexCredentialCatalogError> {
Ok(self.cached(instance)?.and_then(|snapshot| {
Ok(self.cached()?.and_then(|snapshot| {
snapshot
.account_models(account_id, revision)
.map(|models| models.iter().any(|candidate| candidate == model))
@@ -209,25 +193,19 @@ impl CodexCredentialCatalogService {
/// 只刷新指定账号的 realtime catalog,并以本地 revision 原子合并账号事实。
pub async fn synchronize_account(
&self,
instance: &ProviderInstance,
account_id: &ProviderAccountId,
) -> Result<Vec<String>, CodexCredentialCatalogError> {
let config =
CodexProviderInstanceConfig::from_snapshot(instance, self.origin_policy.as_ref())
.map_err(|_| CodexCredentialCatalogError::InvalidInstance)?;
let account = self
.repository
.store()
.get_account(account_id)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?
.filter(|account| {
account.provider().as_str() == "openai" && account.instance() == config.id()
})
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexCredentialCatalogError::NoEligibleCredential)?;
let client = CodexBackendClient::new(
self.http.clone(),
config.base_url().as_str(),
OFFICIAL_CODEX_BASE_URL,
self.profile.clone(),
);
let cache_revision = self.cache_revision()?;
@@ -239,36 +217,28 @@ impl CodexCredentialCatalogService {
.collect::<Vec<_>>();
self.replace_account_cache(
cache_revision,
config.id(),
account_id,
account.revision(),
fetched.models,
entitlement.clone(),
)?;
self.record_applied_catalog_etags(config.id(), fetched.etag)?;
self.record_applied_catalog_etags(fetched.etag)?;
Ok(entitlement)
}
pub async fn synchronize_instance(
pub async fn synchronize(
&self,
instance: &ProviderInstance,
) -> Result<CodexCredentialCatalogSnapshot, CodexCredentialCatalogError> {
if let Some(cached) = self.cached(instance.id())? {
if let Some(cached) = self.cached()? {
return Ok(cached);
}
let cache_revision = self.cache_revision()?;
let fetched = self.fetch_instance_catalog(instance).await?;
self.commit_instance_catalog(cache_revision, fetched)
let fetched = self.fetch_catalog().await?;
self.commit_catalog(cache_revision, fetched)
}
async fn fetch_instance_catalog(
&self,
instance: &ProviderInstance,
) -> Result<FetchedInstanceCatalog, CodexCredentialCatalogError> {
let config =
CodexProviderInstanceConfig::from_snapshot(instance, self.origin_policy.as_ref())
.map_err(|_| CodexCredentialCatalogError::InvalidInstance)?;
let accounts = self.repository.list_for_instance(config.id()).await?;
async fn fetch_catalog(&self) -> Result<FetchedCatalog, CodexCredentialCatalogError> {
let accounts = self.repository.list_for_provider().await?;
let now = SystemTime::now();
let accounts = accounts
.into_iter()
@@ -279,7 +249,7 @@ impl CodexCredentialCatalogService {
}
let client = CodexBackendClient::new(
self.http.clone(),
config.base_url().as_str(),
OFFICIAL_CODEX_BASE_URL,
self.profile.clone(),
);
let mut union = BTreeMap::<String, CodexCatalogModel>::new();
@@ -314,7 +284,6 @@ impl CodexCredentialCatalogService {
}
let observed_at = SystemTime::now();
let snapshot = CodexCredentialCatalogSnapshot {
provider_instance_id: config.id().clone(),
observed_at,
models: union_order
.into_iter()
@@ -322,7 +291,7 @@ impl CodexCredentialCatalogService {
.collect(),
account_models,
};
Ok(FetchedInstanceCatalog { snapshot, etags })
Ok(FetchedCatalog { snapshot, etags })
}
async fn fetch_account_models(
@@ -363,7 +332,6 @@ impl CodexCredentialCatalogService {
fn replace_account_cache(
&self,
expected_cache_revision: u64,
instance: &ProviderInstanceId,
account_id: &ProviderAccountId,
revision: CredentialRevision,
models: Vec<CodexCatalogModel>,
@@ -379,7 +347,7 @@ impl CodexCredentialCatalogService {
let mut union = BTreeMap::<String, CodexCatalogModel>::new();
let mut union_order = Vec::new();
let mut account_models = BTreeMap::new();
if let Some(existing) = cache.snapshots.get(instance) {
if let Some(existing) = cache.snapshot.as_ref() {
for model in &existing.models {
let id = model.request_model().as_str().to_owned();
union_order.push(id.clone());
@@ -414,7 +382,6 @@ impl CodexCredentialCatalogService {
});
union_order.retain(|id| union.contains_key(id));
let snapshot = CodexCredentialCatalogSnapshot {
provider_instance_id: instance.clone(),
observed_at: SystemTime::now(),
models: union_order
.into_iter()
@@ -423,10 +390,10 @@ impl CodexCredentialCatalogService {
account_models,
};
let changed = cache
.snapshots
.get(instance)
.snapshot
.as_ref()
.is_none_or(|existing| !same_catalog(existing, &snapshot));
cache.snapshots.insert(instance.clone(), snapshot);
cache.snapshot = Some(snapshot);
cache.revision = cache.revision.saturating_add(1);
if changed {
cache.generation = cache.generation.saturating_add(1);
@@ -434,15 +401,12 @@ impl CodexCredentialCatalogService {
Ok(())
}
pub fn invalidate(
&self,
instance: &ProviderInstanceId,
) -> Result<(), CodexCredentialCatalogError> {
pub fn invalidate(&self) -> Result<(), CodexCredentialCatalogError> {
let mut cache = self
.cache
.write()
.map_err(|_| CodexCredentialCatalogError::Cache)?;
if cache.snapshots.remove(instance).is_some() {
if cache.snapshot.take().is_some() {
cache.revision = cache.revision.saturating_add(1);
cache.generation = cache.generation.saturating_add(1);
}
@@ -450,33 +414,20 @@ impl CodexCredentialCatalogService {
}
/// 记录普通 Responses 响应声明的目录版本;相同版本只触发一次。
pub fn observe_response_etag(
&self,
instance: &ProviderInstanceId,
etag: &str,
) -> Result<bool, CodexCredentialCatalogError> {
pub fn observe_response_etag(&self, etag: &str) -> Result<bool, CodexCredentialCatalogError> {
validate_response_etag(etag)?;
let changed = {
let mut state = self
.etags
.lock()
.map_err(|_| CodexCredentialCatalogError::Cache)?;
let already_observed = state
.applied
.get(instance)
.is_some_and(|value| value == etag)
|| state
.pending
.get(instance)
.is_some_and(|value| value == etag)
|| state
.inflight
.get(instance)
.is_some_and(|value| value == etag);
let already_observed = state.applied.as_deref() == Some(etag)
|| state.pending.as_deref() == Some(etag)
|| state.inflight.as_deref() == Some(etag);
if already_observed {
false
} else {
state.pending.insert(instance.clone(), etag.to_owned());
state.pending = Some(etag.to_owned());
true
}
};
@@ -486,39 +437,35 @@ impl CodexCredentialCatalogService {
Ok(changed)
}
/// 等待并合并一批需要强制刷新的 Provider instance。
pub async fn wait_for_etag_refresh(&self) -> Vec<ProviderInstanceId> {
/// 等待并认领一次需要强制刷新的 Provider 目录。
pub async fn wait_for_etag_refresh(&self) {
loop {
let pending = self.begin_pending_etag_refreshes();
if !pending.is_empty() {
return pending;
if self.begin_pending_etag_refresh() {
return;
}
self.etag_notification.notified().await;
}
}
/// 忽略当前 cache,按 ETag 变化强制生成一份完整新快照。
pub async fn refresh_instance(
pub async fn refresh(
&self,
instance: &ProviderInstance,
) -> Result<CodexCredentialCatalogSnapshot, CodexCredentialCatalogError> {
let result = self.refresh_instance_inner(instance).await;
self.finish_etag_refresh(instance.id(), result.is_ok())?;
let result = self.refresh_inner().await;
self.finish_etag_refresh(result.is_ok())?;
result
}
async fn refresh_instance_inner(
async fn refresh_inner(
&self,
instance: &ProviderInstance,
) -> Result<CodexCredentialCatalogSnapshot, CodexCredentialCatalogError> {
let cache_revision = self.cache_revision()?;
let fetched = self.fetch_instance_catalog(instance).await?;
self.commit_instance_catalog(cache_revision, fetched)
let fetched = self.fetch_catalog().await?;
self.commit_catalog(cache_revision, fetched)
}
fn record_applied_catalog_etags(
&self,
instance: &ProviderInstanceId,
etags: impl IntoIterator<Item = String>,
) -> Result<(), CodexCredentialCatalogError> {
let mut distinct = etags.into_iter().collect::<std::collections::BTreeSet<_>>();
@@ -532,39 +479,37 @@ impl CodexCredentialCatalogService {
self.etags
.lock()
.map_err(|_| CodexCredentialCatalogError::Cache)?
.applied
.insert(instance.clone(), etag);
.applied = Some(etag);
Ok(())
}
fn begin_pending_etag_refreshes(&self) -> Vec<ProviderInstanceId> {
fn begin_pending_etag_refresh(&self) -> bool {
let Ok(mut state) = self.etags.lock() else {
return Vec::new();
return false;
};
let pending = std::mem::take(&mut state.pending);
let instances = pending.keys().cloned().collect();
state.inflight.extend(pending);
instances
let Some(pending) = state.pending.take() else {
return false;
};
state.inflight = Some(pending);
true
}
fn finish_etag_refresh(
&self,
instance: &ProviderInstanceId,
succeeded: bool,
) -> Result<(), CodexCredentialCatalogError> {
fn finish_etag_refresh(&self, succeeded: bool) -> Result<(), CodexCredentialCatalogError> {
let should_retry = {
let mut state = self
.etags
.lock()
.map_err(|_| CodexCredentialCatalogError::Cache)?;
let Some(etag) = state.inflight.remove(instance) else {
let Some(etag) = state.inflight.take() else {
return Ok(());
};
if succeeded {
state.applied.insert(instance.clone(), etag);
state.applied = Some(etag);
false
} else {
state.pending.entry(instance.clone()).or_insert(etag);
if state.pending.is_none() {
state.pending = Some(etag);
}
true
}
};
@@ -581,12 +526,11 @@ impl CodexCredentialCatalogService {
.map_err(|_| CodexCredentialCatalogError::Cache)
}
fn commit_instance_catalog(
fn commit_catalog(
&self,
expected_cache_revision: u64,
fetched: FetchedInstanceCatalog,
fetched: FetchedCatalog,
) -> Result<CodexCredentialCatalogSnapshot, CodexCredentialCatalogError> {
let instance = fetched.snapshot.provider_instance_id.clone();
let snapshot = fetched.snapshot;
let mut cache = self
.cache
@@ -596,16 +540,16 @@ impl CodexCredentialCatalogService {
return Err(CodexCredentialCatalogError::ConcurrentUpdate);
}
let changed = cache
.snapshots
.get(&instance)
.snapshot
.as_ref()
.is_none_or(|existing| !same_catalog(existing, &snapshot));
cache.snapshots.insert(instance.clone(), snapshot.clone());
cache.snapshot = Some(snapshot.clone());
cache.revision = cache.revision.saturating_add(1);
if changed {
cache.generation = cache.generation.saturating_add(1);
}
drop(cache);
self.record_applied_catalog_etags(&instance, fetched.etags)?;
self.record_applied_catalog_etags(fetched.etags)?;
Ok(snapshot)
}
}
@@ -614,9 +558,7 @@ fn same_catalog(
left: &CodexCredentialCatalogSnapshot,
right: &CodexCredentialCatalogSnapshot,
) -> bool {
left.provider_instance_id == right.provider_instance_id
&& left.models == right.models
&& left.account_models == right.account_models
left.models == right.models && left.account_models == right.account_models
}
fn validate_response_etag(etag: &str) -> Result<(), CodexCredentialCatalogError> {
@@ -131,7 +131,6 @@ pub struct CodexPendingAuthorization {
flow_id: String,
owner_ref: String,
started_request_ref: String,
provider_instance_id: String,
name: String,
expires_at: DateTime<Utc>,
state: SecretString,
@@ -145,7 +144,6 @@ pub struct StoredCodexPendingAuthorization {
pub flow_id: String,
pub owner_ref: String,
pub started_request_ref: String,
pub provider_instance_id: String,
pub name: String,
pub expires_at: DateTime<Utc>,
pub state: SecretString,
@@ -177,7 +175,6 @@ impl CodexPendingAuthorization {
flow_id: input.flow_id,
owner_ref: input.owner_ref,
started_request_ref: input.started_request_ref,
provider_instance_id: input.provider_instance_id,
name: input.name,
expires_at: input.expires_at,
state: input.state,
@@ -189,7 +186,6 @@ impl CodexPendingAuthorization {
if !valid_text(&pending.flow_id)
|| !valid_text(&pending.owner_ref)
|| !valid_text(&pending.started_request_ref)
|| !valid_text(&pending.provider_instance_id)
|| !valid_text(&pending.name)
|| pending.expires_at <= Utc::now()
|| !valid_secret(pending.state.expose_secret())
@@ -217,11 +213,6 @@ impl CodexPendingAuthorization {
&self.started_request_ref
}
#[must_use]
pub fn provider_instance_id(&self) -> &str {
&self.provider_instance_id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
@@ -265,7 +256,6 @@ impl fmt::Debug for CodexPendingAuthorization {
.field("flow_id", &"<redacted>")
.field("owner_ref", &"<redacted>")
.field("started_request_ref", &"<redacted>")
.field("provider_instance_id", &self.provider_instance_id)
.field("name", &self.name)
.field("expires_at", &self.expires_at)
.field("state", &"<redacted>")
@@ -372,13 +362,9 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
&self,
command: StartCodexOAuthAuthorization,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
let (provider_instance_id, name, reauthorization) = match command.mutation.target() {
AuthorizationMutationTarget::Create {
provider_instance_id,
name,
} => (provider_instance_id.to_string(), name.clone(), None),
let (name, reauthorization) = match command.mutation.target() {
AuthorizationMutationTarget::Create { name } => (name.clone(), None),
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} => {
@@ -391,12 +377,10 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
.map_err(map_store_error)?;
if current.account.provider().as_str() != "openai"
|| current.account.id() != account_id
|| current.account.instance() != provider_instance_id
{
return Err(CodexOAuthAdminError::NotFound);
}
(
current.account.instance().to_string(),
current.account.name().to_owned(),
Some(CodexOAuthReauthorizationTarget {
account_id: account_id.clone(),
@@ -405,13 +389,8 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
)
}
};
self.start_pending(
command.mutation,
provider_instance_id,
name,
reauthorization,
)
.await
self.start_pending(command.mutation, name, reauthorization)
.await
}
async fn complete_authorization(
@@ -487,7 +466,6 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
self.credentials
.prepare_import(ImportCodexOAuthCredential {
account_id,
provider_instance_id: pending.provider_instance_id,
name: pending.name,
secret,
verified_account: profile,
@@ -508,11 +486,10 @@ impl CodexOAuthAdminService {
async fn start_pending(
&self,
mutation: PendingAuthorizationMutation,
provider_instance_id: String,
name: String,
reauthorization: Option<CodexOAuthReauthorizationTarget>,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
if !valid_text(&provider_instance_id) || !valid_text(&name) {
if !valid_text(&name) {
return Err(CodexOAuthAdminError::InvalidInput);
}
let expires_at = Utc::now()
@@ -524,7 +501,6 @@ impl CodexOAuthAdminService {
flow_id: random_secret()?,
owner_ref,
started_request_ref,
provider_instance_id,
name,
expires_at,
state: SecretString::from(random_secret()?),
@@ -699,23 +675,15 @@ fn pending_mutation_matches(pending: &CodexPendingAuthorization) -> bool {
return false;
}
match (pending.mutation.target(), pending.reauthorization.as_ref()) {
(
AuthorizationMutationTarget::Create {
provider_instance_id,
name,
},
None,
) => provider_instance_id.as_str() == pending.provider_instance_id && name == &pending.name,
(AuthorizationMutationTarget::Create { name }, None) => name == &pending.name,
(
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
},
Some(target),
) => {
provider_instance_id.as_str() == pending.provider_instance_id
&& account_id == target.account_id()
account_id == target.account_id()
&& expected_credential_revision.get() == target.credential_revision().get()
}
_ => false,
@@ -9,7 +9,6 @@ use gateway_core::engine::credential::{
AccountAvailability, AccountQuotaSignals, CredentialRevision, OpaqueProviderData,
ProviderAccount, ProviderAccountId, ProviderAccountStore, QuotaObservation, QuotaWriteOutcome,
};
use gateway_core::routing::ProviderInstance;
use gateway_protocol::openai::events::{
ParsedRateLimits, RateLimitDetails, RateLimitWindow, parse_rate_limit_headers,
};
@@ -20,7 +19,7 @@ use thiserror::Error;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::provider::{CodexOriginPolicy, CodexProviderInstanceConfig};
use crate::provider::OFFICIAL_CODEX_BASE_URL;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{CodexBackendClient, CodexClientError, CodexRequestContext};
@@ -173,8 +172,6 @@ impl CodexQuotaSyncSummary {
#[derive(Debug, Error)]
pub enum CodexCredentialQuotaError {
#[error("Codex quota instance is invalid")]
InvalidInstance,
#[error("Codex quota response is invalid")]
InvalidCredentialData,
#[error(transparent)]
@@ -200,7 +197,6 @@ pub struct CodexCredentialQuotaService {
store: Arc<dyn ProviderAccountStore>,
profile: CodexWireProfileState,
http: Client,
origin_policy: Arc<dyn CodexOriginPolicy>,
scheduling: CodexQuotaSchedulingProjection,
}
@@ -377,14 +373,12 @@ impl CodexCredentialQuotaService {
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: Client,
origin_policy: Arc<dyn CodexOriginPolicy>,
) -> Self {
Self {
store: Arc::clone(repository.store()),
repository,
profile,
http,
origin_policy,
scheduling: CodexQuotaSchedulingProjection::default(),
}
}
@@ -433,19 +427,13 @@ impl CodexCredentialQuotaService {
self.scheduling.signals(account)
}
pub async fn synchronize_instance(
&self,
instance: &gateway_core::routing::ProviderInstance,
) -> Result<CodexQuotaSyncSummary, CodexCredentialQuotaError> {
let config =
CodexProviderInstanceConfig::from_snapshot(instance, self.origin_policy.as_ref())
.map_err(|_| CodexCredentialQuotaError::InvalidInstance)?;
pub async fn synchronize(&self) -> Result<CodexQuotaSyncSummary, CodexCredentialQuotaError> {
let client = CodexBackendClient::new(
self.http.clone(),
config.base_url().as_str(),
OFFICIAL_CODEX_BASE_URL,
self.profile.clone(),
);
let accounts = self.repository.list_for_instance(config.id()).await?;
let accounts = self.repository.list_for_provider().await?;
let mut summary = CodexQuotaSyncSummary::default();
let now = SystemTime::now();
for account in accounts
@@ -677,24 +665,18 @@ impl CodexCredentialQuotaService {
/// 只刷新指定账号,revision-fenced 写入动态 Provider JSON 后返回解析快照。
pub async fn refresh_account(
&self,
instance: &ProviderInstance,
account_id: &ProviderAccountId,
) -> Result<CodexAccountQuotaSnapshot, CodexCredentialQuotaError> {
let config =
CodexProviderInstanceConfig::from_snapshot(instance, self.origin_policy.as_ref())
.map_err(|_| CodexCredentialQuotaError::InvalidInstance)?;
let account = self
.store
.get_account(account_id)
.await?
.filter(|account| {
account.provider().as_str() == "openai" && account.instance() == config.id()
})
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexCredentialQuotaError::NotFound)?;
let runtime = self.repository.load_runtime_credential(&account).await?;
let client = CodexBackendClient::new(
self.http.clone(),
config.base_url().as_str(),
OFFICIAL_CODEX_BASE_URL,
self.profile.clone(),
);
let request_id = format!("quota_{}", Uuid::now_v7().simple());
@@ -9,7 +9,7 @@ use gateway_core::engine::credential::{
CredentialRevision, LoadedCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
ProviderAccountUpdate,
};
use gateway_core::routing::ProviderInstanceId;
use gateway_core::routing::ProviderKind;
use secrecy::ExposeSecret;
use thiserror::Error;
@@ -157,12 +157,13 @@ impl CodexCredentialRepository {
cas_revision(self.store.compare_and_swap_credential(update).await?)
}
pub async fn list_for_instance(
pub async fn list_for_provider(
&self,
instance: &ProviderInstanceId,
) -> Result<Vec<ProviderAccount>, CredentialRepositoryError> {
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
self.store
.list_for_instance(instance)
.list_for_provider(&provider)
.await
.map_err(Into::into)
}
@@ -14,7 +14,7 @@ use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest,
ProviderSchedulingLeaseRequest, ProviderStoreError,
};
use gateway_core::routing::ProviderInstanceId;
use gateway_core::routing::ProviderKind;
use secrecy::ExposeSecret;
use thiserror::Error;
use url::Url;
@@ -25,7 +25,6 @@ use super::quota::CodexCredentialQuotaService;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::types::{CodexCookie, CodexCookieCaptureOutcome, CodexOAuthSecret, RuntimeCodexCookie};
const PROVIDER_NAME: &str = "openai";
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
const CLOUDFLARE_PATH_BLOCK_COOLDOWN: Duration = Duration::from_secs(30);
const CLOUDFLARE_RECOVERY_STALE_AFTER: Duration = Duration::from_secs(60 * 60);
@@ -76,13 +75,13 @@ enum CookieRecovery {
}
pub struct SelectCodexCredential<'a> {
pub provider_instance_id: &'a ProviderInstanceId,
pub upstream_model: &'a str,
pub request_url: &'a Url,
pub attempt: &'a AttemptContext,
}
pub struct CodexCredentialSelector {
provider_kind: ProviderKind,
repository: CodexCredentialRepository,
leases: Arc<dyn ProviderLeasePort>,
catalog: Arc<CodexCredentialCatalogService>,
@@ -94,6 +93,7 @@ pub struct CodexCredentialSelector {
impl CodexCredentialSelector {
#[must_use]
pub fn new(
provider_kind: ProviderKind,
repository: CodexCredentialRepository,
leases: Arc<dyn ProviderLeasePort>,
catalog: Arc<CodexCredentialCatalogService>,
@@ -101,6 +101,7 @@ impl CodexCredentialSelector {
cookie_policy: CodexCookiePolicy,
) -> Self {
Self {
provider_kind,
repository,
leases,
catalog,
@@ -114,18 +115,13 @@ impl CodexCredentialSelector {
&self,
request: &SelectCodexCredential<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let accounts = self
.repository
.list_for_instance(request.provider_instance_id)
.await?;
let accounts = self.repository.list_for_provider().await?;
let accounts = accounts
.into_iter()
.filter(|account| {
account.provider().as_str() == PROVIDER_NAME
&& account.instance() == request.provider_instance_id
account.provider() == &self.provider_kind
&& !matches!(
self.catalog.observed_model_support(
request.provider_instance_id,
account.id(),
account.revision(),
request.upstream_model,
@@ -141,7 +137,7 @@ impl CodexCredentialSelector {
.collect::<Vec<_>>();
let scheduling = self
.leases
.load_state(request.provider_instance_id, &account_ids)
.load_state(&self.provider_kind, &account_ids)
.await?;
let round_robin_cursor = scheduling.round_robin_cursor();
let candidates = accounts
@@ -171,10 +167,7 @@ impl CodexCredentialSelector {
ContinuationAttempt::ReplayOwner => request
.attempt
.account_state_owner()
.filter(|owner| {
owner.provider().as_str() == PROVIDER_NAME
&& owner.instance() == request.provider_instance_id
})
.filter(|owner| owner.provider() == &self.provider_kind)
.map(|owner| owner.account().clone()),
ContinuationAttempt::None | ContinuationAttempt::ReplayAny => None,
};
@@ -219,7 +212,7 @@ impl CodexCredentialSelector {
.leases
.try_acquire(ProviderLeaseRequest::Scheduling(
ProviderSchedulingLeaseRequest::new(
request.provider_instance_id.clone(),
self.provider_kind.clone(),
account.id().clone(),
account.revision(),
policy.max_concurrent_per_account(),
+20 -27
View File
@@ -32,8 +32,8 @@ use crate::transport::{CodexWebSocketPool, build_reqwest_client};
pub use config::{CodexWireProfileConfig, OpenAiConfig, OpenAiConfigError};
pub use provider::{
CodexOriginPolicy, CodexProvider, CodexProviderConfigError, CodexProviderInstanceConfig,
CodexProviderTransport, OFFICIAL_CODEX_BASE_PATH, OfficialCodexOriginPolicy,
CodexProvider, CodexProviderConfigError, CodexProviderTransport, OFFICIAL_CODEX_BASE_PATH,
OFFICIAL_CODEX_BASE_URL,
};
pub mod credential;
@@ -64,10 +64,8 @@ pub async fn initialize(
let accounts: Arc<dyn ProviderAccountStore> = ports.accounts();
let leases = ports.leases();
let runtime_policy = ports.runtime_policy();
let instances = ports.instances();
let profile = config.wire_profile_state();
let http = build_reqwest_client().map_err(|_| OpenAiInitializeError::Transport)?;
let origin_policy: Arc<dyn CodexOriginPolicy> = Arc::new(OfficialCodexOriginPolicy);
let desktop_release = Arc::new(CodexDesktopReleaseService::new(
profile.clone(),
Arc::new(
@@ -88,15 +86,14 @@ pub async fn initialize(
repository.clone(),
profile.clone(),
http.clone(),
Arc::clone(&origin_policy),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
repository.clone(),
profile.clone(),
http.clone(),
Arc::clone(&origin_policy),
));
let selector = Arc::new(CodexCredentialSelector::new(
provider_kind.clone(),
repository.clone(),
Arc::clone(&leases),
Arc::clone(&catalog),
@@ -104,15 +101,17 @@ pub async fn initialize(
CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?,
));
let websocket_pool = Arc::new(CodexWebSocketPool::default());
let core_provider: Arc<dyn Provider> = Arc::new(CodexProvider::new(
selector,
Arc::clone(&catalog),
Arc::clone(&quota),
http,
profile.clone(),
Arc::clone(&websocket_pool),
origin_policy,
));
let core_provider: Arc<dyn Provider> = Arc::new(
CodexProvider::new(
selector,
Arc::clone(&catalog),
Arc::clone(&quota),
http,
profile.clone(),
Arc::clone(&websocket_pool),
)
.map_err(OpenAiInitializeError::Provider)?,
);
let token_client = Arc::new(
credential::token_client::official_openai_token_client()
@@ -156,10 +155,9 @@ pub async fn initialize(
CodexCredentialAdmin,
));
let admin_provider: Arc<dyn ProviderAdmin> = Arc::new(OpenAiAdminProvider::new(
provider_kind.clone(),
provider_kind,
profile,
accounts,
Arc::clone(&instances),
OpenAiAdminServices {
credentials: credential_admin,
verifier: identity,
@@ -171,16 +169,9 @@ pub async fn initialize(
websocket_pool,
desktop_release_status,
));
let worker_contributions = provider::worker_contributions(
refresh,
quota,
catalog,
cli_release,
desktop_release,
instances,
provider_kind,
)
.map_err(|_| OpenAiInitializeError::Worker)?;
let worker_contributions =
provider::worker_contributions(refresh, quota, catalog, cli_release, desktop_release)
.map_err(|_| OpenAiInitializeError::Worker)?;
Ok(ProviderBundle {
core_provider,
@@ -217,6 +208,8 @@ pub enum OpenAiInitializeError {
InvalidProviderKind,
#[error("OpenAI transport could not initialize")]
Transport,
#[error(transparent)]
Provider(CodexProviderConfigError),
#[error("OpenAI cookie policy could not initialize")]
CookiePolicy,
#[error("OpenAI token client could not initialize")]
+50 -298
View File
@@ -1,8 +1,8 @@
//! Codex 的 `gateway-core` Provider adapter。
use std::collections::{BTreeSet, HashMap};
use std::collections::BTreeSet;
use std::fmt;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
@@ -22,11 +22,7 @@ use gateway_core::event::{
ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
};
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProviderSessionState};
use gateway_core::provider_ports::ProviderInstanceCatalogPort;
use gateway_core::routing::{
InstanceHealth, ModelCapabilities, ProviderInstance, ProviderInstanceId, ProviderKind,
UpstreamModelId,
};
use gateway_core::routing::{ModelCapabilities, ProviderKind, UpstreamModelId};
use gateway_core::task::{
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
WorkerDefinitionError, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
@@ -36,7 +32,7 @@ use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::{Host, Url};
use url::Url;
use crate::credential::{
CodexAccountFailure, CodexCredentialCatalogService, CodexCredentialLease,
@@ -70,8 +66,8 @@ const PROVIDER_NAME: &str = "openai";
const HTTP_SSE_TRANSPORT: &str = "http_sse";
const WEBSOCKET_TRANSPORT: &str = "websocket";
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
const OFFICIAL_CODEX_HOST: &str = "chatgpt.com";
pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api";
pub const OFFICIAL_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexProviderTransport {
@@ -89,92 +85,18 @@ impl CodexProviderTransport {
}
}
/// Provider base URL 的显式信任边界。
///
/// 生产组装注入 [`OfficialCodexOriginPolicy`];Provider 只依赖这个端口,
/// 不包含部署环境或地址类型分支。
pub trait CodexOriginPolicy: Send + Sync {
fn allows(&self, url: &Url) -> bool;
}
#[derive(Debug, Default)]
pub struct OfficialCodexOriginPolicy;
impl CodexOriginPolicy for OfficialCodexOriginPolicy {
fn allows(&self, url: &Url) -> bool {
url.scheme() == "https"
&& matches!(url.host(), Some(Host::Domain(host)) if host == OFFICIAL_CODEX_HOST)
&& url.port().is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexProviderInstanceConfig {
id: ProviderInstanceId,
base_url: Url,
transport: CodexProviderTransport,
}
impl CodexProviderInstanceConfig {
pub fn from_snapshot(
instance: &ProviderInstance,
origin_policy: &dyn CodexOriginPolicy,
) -> Result<Self, CodexProviderConfigError> {
if instance.provider().as_str() != PROVIDER_NAME {
return Err(CodexProviderConfigError::ProviderMismatch);
}
let mut base_url = Url::parse(instance.base_url())
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
normalize_and_validate_base_url(&mut base_url, origin_policy)?;
Ok(Self {
id: instance.id().clone(),
base_url,
transport: CodexProviderTransport::PreferWebSocket,
})
}
#[must_use]
pub const fn id(&self) -> &ProviderInstanceId {
&self.id
}
#[must_use]
pub const fn base_url(&self) -> &Url {
&self.base_url
}
#[must_use]
pub const fn transport(&self) -> CodexProviderTransport {
self.transport
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CodexProviderConfigError {
#[error("provider instance does not belong to Codex")]
ProviderMismatch,
#[error("Codex provider base URL is invalid")]
#[error("official Codex provider URL is invalid")]
InvalidBaseUrl,
#[error("Codex provider base URL is not allowed")]
UnsafeBaseUrl,
}
#[derive(Clone)]
struct CompiledInstance {
config: CodexProviderInstanceConfig,
responses_url: Url,
client: CodexBackendClient,
}
pub struct CodexProvider {
selector: Arc<CodexCredentialSelector>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
http: Client,
profile: CodexWireProfileState,
origin_policy: Arc<dyn CodexOriginPolicy>,
websocket_pool: Arc<CodexWebSocketPool>,
compiled_instances: Mutex<HashMap<String, CompiledInstance>>,
client: CodexBackendClient,
responses_url: Url,
}
impl CodexProvider {
@@ -185,59 +107,19 @@ impl CodexProvider {
http: Client,
profile: CodexWireProfileState,
websocket_pool: Arc<CodexWebSocketPool>,
origin_policy: Arc<dyn CodexOriginPolicy>,
) -> Self {
Self {
) -> Result<Self, CodexProviderConfigError> {
let responses_url =
Url::parse(&endpoint_url(OFFICIAL_CODEX_BASE_URL, CODEX_RESPONSES_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let client = CodexBackendClient::new(http, OFFICIAL_CODEX_BASE_URL, profile)
.with_websocket_pool(websocket_pool);
Ok(Self {
selector,
catalog,
quota,
http,
profile,
origin_policy,
websocket_pool,
compiled_instances: Mutex::new(HashMap::new()),
}
}
pub fn validate_instance(
instance: &ProviderInstance,
) -> Result<CodexProviderInstanceConfig, CodexProviderConfigError> {
CodexProviderInstanceConfig::from_snapshot(instance, &OfficialCodexOriginPolicy)
}
fn compile_instance(
&self,
snapshot: &ProviderInstance,
) -> Result<CompiledInstance, ProviderError> {
let config =
CodexProviderInstanceConfig::from_snapshot(snapshot, self.origin_policy.as_ref())
.map_err(map_instance_config_error)?;
let responses_url = Url::parse(&endpoint_url(
config.base_url.as_str(),
CODEX_RESPONSES_PATH,
))
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let mut instances = self
.compiled_instances
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(compiled) = instances.get(config.id().as_str())
&& compiled.config == config
{
return Ok(compiled.clone());
}
let compiled = CompiledInstance {
client: CodexBackendClient::new(
self.http.clone(),
config.base_url.as_str(),
self.profile.clone(),
)
.with_websocket_pool(Arc::clone(&self.websocket_pool)),
config,
client,
responses_url,
};
instances.insert(compiled.config.id().as_str().to_owned(), compiled.clone());
Ok(compiled)
})
}
}
@@ -247,7 +129,6 @@ impl fmt::Debug for CodexProvider {
.debug_struct("CodexProvider")
.field("selector", &"<account-selector>")
.field("catalog", &"<ttl-catalog>")
.field("origin_policy", &"[ORIGIN_POLICY]")
.finish()
}
}
@@ -291,15 +172,10 @@ impl Provider for CodexProvider {
async fn query_model_capabilities(
&self,
instance: &ProviderInstance,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
let snapshot = self
.catalog
.synchronize_instance(instance)
.await
.map_err(|_| {
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
})?;
let snapshot = self.catalog.synchronize().await.map_err(|_| {
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
})?;
Ok(snapshot
.models()
.iter()
@@ -319,14 +195,6 @@ impl Provider for CodexProvider {
));
}
let candidate = request.candidate();
let instance_snapshot = candidate.instance_snapshot();
if instance_snapshot.id() != candidate.instance() {
return Err(provider_error(
ProviderErrorKind::Protocol,
UpstreamSendState::NotSent,
));
}
let instance = self.compile_instance(instance_snapshot)?;
if context.cancellation().is_cancelled() {
return Err(provider_error(
ProviderErrorKind::Cancelled,
@@ -351,15 +219,14 @@ impl Provider for CodexProvider {
encode_generate_request(generate, candidate.upstream_model().as_str())
.map_err(map_request_error)?;
let request_input = upstream_request.input().to_vec();
let transport = selected_transport(&request, &instance.config)?;
let transport = selected_transport(&request)?;
apply_transport(&mut upstream_request, transport);
let lease = self
.selector
.select(&SelectCodexCredential {
provider_instance_id: candidate.instance(),
upstream_model: candidate.upstream_model().as_str(),
request_url: &instance.responses_url,
request_url: &self.responses_url,
attempt: &context,
})
.await
@@ -367,11 +234,12 @@ impl Provider for CodexProvider {
let lease = Arc::new(lease);
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let cross_account = context.account_state_owner().is_some_and(|owner| {
!owner.matches(&provider_kind, candidate.instance(), lease.account_id())
}) || previous_session
.as_ref()
.is_some_and(|state| state.account_id != lease.account_id().as_str());
let cross_account = context
.account_state_owner()
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| previous_session
.as_ref()
.is_some_and(|state| state.account_id != lease.account_id().as_str());
let replay_previous_response = matches!(
context.continuation_attempt(),
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny
@@ -448,7 +316,6 @@ impl Provider for CodexProvider {
}
let metadata = ProviderCallMetadata::new(
provider_kind,
candidate.instance().clone(),
candidate.upstream_model().clone(),
ProviderResource::Account {
id: lease.account_id().clone(),
@@ -469,8 +336,8 @@ impl Provider for CodexProvider {
continuation_scope: None,
});
let events = cold_response_stream(ColdResponse {
client: instance.client,
response_origin: instance.responses_url,
client: self.client.clone(),
response_origin: self.responses_url.clone(),
request: upstream_request,
upstream_model: candidate.upstream_model().clone(),
transport_policy: transport,
@@ -478,7 +345,6 @@ impl Provider for CodexProvider {
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
catalog: Arc::clone(&self.catalog),
provider_instance_id: candidate.instance().clone(),
lease: Arc::clone(&lease),
session_capture,
});
@@ -496,7 +362,6 @@ struct ColdResponse {
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
catalog: Arc<CodexCredentialCatalogService>,
provider_instance_id: ProviderInstanceId,
lease: Arc<CodexCredentialLease>,
session_capture: Option<OpenAiSessionCapture>,
}
@@ -688,7 +553,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
selector,
quota,
catalog,
provider_instance_id,
lease,
mut session_capture,
} = response;
@@ -764,10 +628,9 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
)?);
synchronize_passive_quota(&quota, &lease, &response.rate_limit_headers).await;
if let Some(etag) = response.response_metadata.models_etag.as_deref()
&& let Err(error) = catalog.observe_response_etag(&provider_instance_id, etag)
&& let Err(error) = catalog.observe_response_etag(etag)
{
tracing::warn!(
provider_instance_id = %provider_instance_id,
error = %error,
"OpenAI model ETag observation was rejected"
);
@@ -1045,11 +908,8 @@ fn compile_model_capabilities(model: &CodexCatalogModel) -> ProviderModelCapabil
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
}
fn selected_transport(
request: &ProviderRequest,
instance: &CodexProviderInstanceConfig,
) -> Result<CodexProviderTransport, ProviderError> {
let mut transport = instance.transport();
fn selected_transport(request: &ProviderRequest) -> Result<CodexProviderTransport, ProviderError> {
let mut transport = CodexProviderTransport::PreferWebSocket;
if let Some(value) = request
.operation()
.provider_options(PROVIDER_NAME)
@@ -1193,16 +1053,6 @@ fn map_request_error(error: CodexRequestEncodeError) -> ProviderError {
provider_error(kind, UpstreamSendState::NotSent)
}
fn map_instance_config_error(error: CodexProviderConfigError) -> ProviderError {
let kind = match error {
CodexProviderConfigError::ProviderMismatch => ProviderErrorKind::InvalidRequest,
CodexProviderConfigError::InvalidBaseUrl | CodexProviderConfigError::UnsafeBaseUrl => {
ProviderErrorKind::Protocol
}
};
provider_error(kind, UpstreamSendState::NotSent)
}
fn map_selection_error(error: CredentialSelectionError) -> ProviderError {
match error {
CredentialSelectionError::CapacityUnavailable { retry_after } => {
@@ -1584,30 +1434,6 @@ fn remaining(deadline: SystemTime) -> Option<Duration> {
.filter(|remaining| !remaining.is_zero())
}
fn normalize_and_validate_base_url(
url: &mut Url,
origin_policy: &dyn CodexOriginPolicy,
) -> Result<(), CodexProviderConfigError> {
if url.cannot_be_a_base()
|| url.host().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(CodexProviderConfigError::UnsafeBaseUrl);
}
if !origin_policy.allows(url) {
return Err(CodexProviderConfigError::UnsafeBaseUrl);
}
let normalized_path = url.path().trim_end_matches('/');
if normalized_path != OFFICIAL_CODEX_BASE_PATH {
return Err(CodexProviderConfigError::UnsafeBaseUrl);
}
url.set_path(OFFICIAL_CODEX_BASE_PATH);
Ok(())
}
const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
@@ -1624,8 +1450,6 @@ pub(crate) fn worker_contributions(
catalog: Arc<CodexCredentialCatalogService>,
cli_release: Arc<CodexCliReleaseService>,
desktop_release: Arc<CodexDesktopReleaseService>,
instances: Arc<dyn ProviderInstanceCatalogPort>,
provider_kind: ProviderKind,
) -> Result<Vec<WorkerContribution>, WorkerDefinitionError> {
let refresh_id = WorkerId::try_new(WorkerKind::OAuthRefresh, PROVIDER_NAME)?;
let quota_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, PROVIDER_NAME)?;
@@ -1643,11 +1467,7 @@ pub(crate) fn worker_contributions(
WorkerContribution::Registration(scheduled_registration(
quota_id,
QUOTA_REFRESH_INTERVAL,
Box::new(OpenAiQuotaTask {
instances: Arc::clone(&instances),
quota,
provider_kind: provider_kind.clone(),
}),
Box::new(OpenAiQuotaTask { quota }),
)?),
WorkerContribution::Registration(WorkerRegistration::try_new(
etag_id,
@@ -1656,11 +1476,7 @@ pub(crate) fn worker_contributions(
WORKER_INITIAL_BACKOFF,
WORKER_MAXIMUM_BACKOFF,
)?,
task: Box::new(OpenAiCatalogEtagTask {
instances,
catalog,
provider_kind,
}),
task: Box::new(OpenAiCatalogEtagTask { catalog }),
},
)?),
WorkerContribution::Registration(scheduled_registration(
@@ -1741,15 +1557,11 @@ impl ScheduledTask for OpenAiOAuthRefreshTask {
}
struct OpenAiQuotaTask {
instances: Arc<dyn ProviderInstanceCatalogPort>,
quota: Arc<CodexCredentialQuotaService>,
provider_kind: ProviderKind,
}
struct OpenAiCatalogEtagTask {
instances: Arc<dyn ProviderInstanceCatalogPort>,
catalog: Arc<CodexCredentialCatalogService>,
provider_kind: ProviderKind,
}
struct OpenAiDesktopReleaseTask {
@@ -1799,48 +1611,16 @@ impl ScheduledTask for OpenAiDesktopReleaseTask {
impl ScheduledTask for OpenAiQuotaTask {
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let instances = self
.instances
.list_instances(&self.provider_kind, false)
.await
.map_err(|_| WorkerTaskError::safe("OpenAI Provider instances unavailable"))?;
let mut failures = 0_u64;
for config in instances {
if context.cancellation().is_cancelled() {
return Ok(());
}
if !config.enabled() || config.provider_kind() != &self.provider_kind {
failures = failures.saturating_add(1);
continue;
}
let instance = ProviderInstance::new(
config.id().clone(),
config.provider_kind().clone(),
config.base_url().to_owned(),
config.enabled(),
InstanceHealth::Healthy,
);
match self.quota.synchronize_instance(&instance).await {
Ok(summary) if summary.has_operational_failures() => {
tracing::warn!(
provider_instance_id = %instance.id(),
"OpenAI quota cycle contained operational failures"
);
}
Ok(_) => {}
Err(_) => {
failures = failures.saturating_add(1);
tracing::warn!(
provider_instance_id = %instance.id(),
"OpenAI quota synchronization failed"
);
}
}
if context.cancellation().is_cancelled() {
return Ok(());
}
if failures == 0 {
Ok(())
} else {
Err(WorkerTaskError::safe("OpenAI quota synchronization failed"))
match self.quota.synchronize().await {
Ok(summary) if summary.has_operational_failures() => {
tracing::warn!("OpenAI quota cycle contained operational failures");
Ok(())
}
Ok(_) => Ok(()),
Err(_) => Err(WorkerTaskError::safe("OpenAI quota synchronization failed")),
}
})
}
@@ -1853,43 +1633,15 @@ impl DaemonTask for OpenAiCatalogEtagTask {
) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
loop {
let changed = tokio::select! {
tokio::select! {
() = cancellation.cancelled() => return Ok(()),
changed = self.catalog.wait_for_etag_refresh() => changed,
() = self.catalog.wait_for_etag_refresh() => {},
};
let instances = self
.instances
.list_instances(&self.provider_kind, false)
.await
.map_err(|_| {
WorkerTaskError::safe(
"OpenAI Provider instances unavailable for model ETag refresh",
)
})?;
for config in instances {
if cancellation.is_cancelled() {
return Ok(());
}
if !changed.contains(config.id())
|| !config.enabled()
|| config.provider_kind() != &self.provider_kind
{
continue;
}
let instance = ProviderInstance::new(
config.id().clone(),
config.provider_kind().clone(),
config.base_url().to_owned(),
true,
InstanceHealth::Healthy,
if let Err(error) = self.catalog.refresh().await {
tracing::warn!(
error = %error,
"OpenAI model catalog ETag refresh failed"
);
if let Err(error) = self.catalog.refresh_instance(&instance).await {
tracing::warn!(
provider_instance_id = %instance.id(),
error = %error,
"OpenAI model catalog ETag refresh failed"
);
}
}
}
})
+12 -52
View File
@@ -37,14 +37,13 @@ use gateway_core::operation::{
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingTakeOutcome,
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogPorts, ProviderCooldown,
ProviderCooldownPort, ProviderCredentialState, ProviderCredentialStatePort,
ProviderInstanceCatalogPort, ProviderInstanceConfig, ProviderRefreshPolicy,
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderStoreError, ProviderStorePorts,
};
use gateway_core::routing::{
ConfigRevision, InstanceHealth, ModelCapabilities, ProviderInstance, ProviderInstanceId,
ProviderKind, ProviderModel, PublicModelId, RoutingContext, RuntimeSnapshot, UpstreamModelId,
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
@@ -53,7 +52,7 @@ use provider_openai::transport::profile::APPCAST_POLL_INTERVAL;
use serde_json::{Map, Value, json};
use std::collections::BTreeSet;
use crate::support::{MemoryAccountStore, TestLeaseCoordinator, instance_id, profile, secret};
use crate::support::{MemoryAccountStore, TestLeaseCoordinator, profile, secret};
#[tokio::test]
#[ignore = "requires CODEX_REAL_ACCOUNT_FIXTURE and consumes live OpenAI quota"]
@@ -75,7 +74,6 @@ async fn real_openai_conversation_crosses_production_provider_boundaries() {
let prepared = bundle
.admin_provider()
.prepare_import(PrepareCredentialImport {
provider_instance_id: instance_id(),
document: ProviderDocument::new(OpaqueProviderData::new(document)),
})
.await
@@ -87,16 +85,9 @@ async fn real_openai_conversation_crosses_production_provider_boundaries() {
.expect("seed verified OpenAI account");
}
let instance = ProviderInstance::new(
instance_id(),
ProviderKind::new("openai").expect("provider"),
"https://chatgpt.com/backend-api".to_owned(),
true,
InstanceHealth::Healthy,
);
let models = bundle
.core_provider()
.query_model_capabilities(&instance)
.query_model_capabilities()
.await
.expect("query live OpenAI catalog");
let model = models.first().expect("one live generation model");
@@ -104,7 +95,7 @@ async fn real_openai_conversation_crosses_production_provider_boundaries() {
.admin_provider()
.connection_test_operation(model.upstream_model(), "Reply with exactly CPR_REAL_OK.")
.expect("real connection operation");
let request = planned_real_request(instance, model.upstream_model().clone(), operation);
let request = planned_real_request(model.upstream_model().clone(), operation);
let mut stream = bundle
.core_provider()
.execute(request, real_attempt_context())
@@ -133,7 +124,6 @@ fn prepared_account(
) -> NewProviderAccount {
let account = ProviderAccount::new(
value.account_id,
value.provider_instance_id,
value.provider_kind,
value.name,
value.upstream_user_id,
@@ -154,11 +144,8 @@ fn prepared_account(
}
}
fn planned_real_request(
instance: ProviderInstance,
upstream_model: UpstreamModelId,
operation: Operation,
) -> ProviderRequest {
fn planned_real_request(upstream_model: UpstreamModelId, operation: Operation) -> ProviderRequest {
let provider = ProviderKind::new("openai").expect("provider");
let public_model = PublicModelId::new(upstream_model.as_str()).expect("public model");
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
@@ -167,9 +154,9 @@ fn planned_real_request(
NonZeroU32::new(1).expect("concurrency"),
Duration::ZERO,
),
vec![instance.clone()],
vec![provider.clone()],
vec![ProviderModel::new(
instance.id().clone(),
provider,
upstream_model,
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), 1_000_000, None),
)],
@@ -351,7 +338,6 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
Revision::new(7).expect("revision"),
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Create {
provider_instance_id: instance_id(),
name: "OAuth account".to_owned(),
},
AuthorizationOwnerBinding::from_context(&start_context),
@@ -401,7 +387,6 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_admin_projection".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "admin projection".to_owned(),
secret: secret("admin-projection-access"),
verified_account: profile("chatgpt-admin-projection"),
@@ -487,7 +472,6 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_admin_invalid".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "admin invalid".to_owned(),
secret: secret("admin-invalid-access"),
verified_account: profile("chatgpt-admin-invalid"),
@@ -506,7 +490,6 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
let admin = bundle.admin_provider();
let import_error = admin
.prepare_import(PrepareCredentialImport {
provider_instance_id: instance_id(),
document: ProviderDocument::new(OpaqueProviderData::new(Map::new())),
})
.await
@@ -544,10 +527,7 @@ fn provider_ports_with(
ProviderStorePorts::new(
accounts,
Arc::new(TestLeaseCoordinator::default()),
ProviderCatalogPorts::new(
Arc::new(TestInstanceCatalog),
Arc::new(TestCatalogCache::default()),
),
Arc::new(TestCatalogCache::default()),
Arc::new(TestCredentialState),
Arc::new(TestCooldown),
Arc::new(TestRuntimePolicy),
@@ -559,7 +539,6 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
let now = Utc::now();
AccountRecord {
id: account.id().to_string(),
provider_instance_id: account.instance().clone(),
provider_kind: account.provider().clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
@@ -600,25 +579,6 @@ fn valid_config() -> OpenAiConfig {
}
}
struct TestInstanceCatalog;
impl ProviderInstanceCatalogPort for TestInstanceCatalog {
fn list_instances<'a>(
&'a self,
provider_kind: &'a ProviderKind,
_include_disabled: bool,
) -> BoxFuture<'a, Result<Vec<ProviderInstanceConfig>, ProviderStoreError>> {
Box::pin(async move {
Ok(vec![ProviderInstanceConfig::new(
ProviderInstanceId::new("inst_openai_primary").expect("instance"),
provider_kind.clone(),
"https://chatgpt.com/backend-api".to_owned(),
true,
)])
})
}
}
#[derive(Default)]
struct TestCatalogCache {
values: Mutex<BTreeMap<String, OpaqueProviderData>>,
@@ -23,14 +23,11 @@ use provider_openai::credential::{
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use secrecy::{ExposeSecret, SecretString};
use crate::support::{
MemoryAccountStore, codex_account, instance_id, profile, runtime_policy, secret,
};
use crate::support::{MemoryAccountStore, codex_account, profile, runtime_policy, secret};
fn import(id: &str, token: &str) -> ImportCodexOAuthCredential {
ImportCodexOAuthCredential {
account_id: id.to_owned(),
provider_instance_id: instance_id().to_string(),
name: format!("name-{id}"),
secret: secret(token),
verified_account: profile(&format!("chatgpt-{id}")),
@@ -270,7 +267,7 @@ struct ManualLeases {
impl ProviderLeasePort for ManualLeases {
fn load_state<'a>(
&'a self,
_: &'a gateway_core::routing::ProviderInstanceId,
_: &'a gateway_core::routing::ProviderKind,
_: &'a [ProviderAccountId],
) -> BoxFuture<
'a,
@@ -321,7 +318,6 @@ async fn manual_refresh_fixture(
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_manual_refresh".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "manual refresh".to_owned(),
secret: secret("old-access"),
verified_account: profile("chatgpt-acct_manual_refresh"),
@@ -507,26 +503,23 @@ fn unused_import_refresher() -> Arc<ManualRefresher> {
async fn formal_cpr_import_is_strict_and_uses_the_single_core_write_shape() {
let refresher = unused_import_refresher();
let prepared = import_service(refresher.clone())
.prepare_import_document(
instance_id(),
serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_cpr_import",
"email": "cpr@example.com",
"accountId": "chatgpt-cpr",
"userId": "user-chatgpt-cpr",
"label": "CPR account",
"planType": "pro",
"token": "Bearer token-cpr",
"refreshToken": "refresh-cpr",
"accessTokenExpiresAt": "2100-01-01T00:00:00+00:00",
"status": "disabled",
"addedAt": "2026-07-18T10:47:01+08:00",
"updatedAt": "2026-07-19T11:00:00+08:00"
}]
}),
)
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_cpr_import",
"email": "cpr@example.com",
"accountId": "chatgpt-cpr",
"userId": "user-chatgpt-cpr",
"label": "CPR account",
"planType": "pro",
"token": "Bearer token-cpr",
"refreshToken": "refresh-cpr",
"accessTokenExpiresAt": "2100-01-01T00:00:00+00:00",
"status": "disabled",
"addedAt": "2026-07-18T10:47:01+08:00",
"updatedAt": "2026-07-19T11:00:00+08:00"
}]
}))
.await
.expect("CPR import");
assert_eq!(prepared.accounts().len(), 1);
@@ -539,13 +532,10 @@ async fn formal_cpr_import_is_strict_and_uses_the_single_core_write_shape() {
assert!(refresher.seen.lock().expect("seen tokens").is_empty());
let error = import_service(unused_import_refresher())
.prepare_import_document(
instance_id(),
serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{"token": "token-cpr", "unexpected": true}]
}),
)
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{"token": "token-cpr", "unexpected": true}]
}))
.await
.expect_err("unknown CPR account key");
assert_eq!(error, CodexCredentialAdminError::InvalidInput);
@@ -554,18 +544,15 @@ async fn formal_cpr_import_is_strict_and_uses_the_single_core_write_shape() {
#[tokio::test]
async fn cpr_import_uses_verified_token_identity_instead_of_stale_export_projections() {
let error = import_service(unused_import_refresher())
.prepare_import_document(
instance_id(),
serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_verified_identity",
"accountId": "user-chatgpt-cpr",
"userId": "user-chatgpt-cpr",
"token": "token-cpr"
}]
}),
)
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_verified_identity",
"accountId": "user-chatgpt-cpr",
"userId": "user-chatgpt-cpr",
"token": "token-cpr"
}]
}))
.await
.expect_err("stale document identity must not override authenticated identity");
@@ -575,23 +562,20 @@ async fn cpr_import_uses_verified_token_identity_instead_of_stale_export_project
#[tokio::test]
async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
let prepared = import_service(unused_import_refresher())
.prepare_import_document(
instance_id(),
serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_shared_a",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
}, {
"id": "acct_shared_b",
"accountId": "chatgpt-shared",
"userId": "user-b",
"token": "token-shared-b"
}]
}),
)
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_shared_a",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
}, {
"id": "acct_shared_b",
"accountId": "chatgpt-shared",
"userId": "user-b",
"token": "token-shared-b"
}]
}))
.await
.expect("distinct users sharing one workspace are separate credentials");
@@ -604,23 +588,20 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
);
let duplicate = import_service(unused_import_refresher())
.prepare_import_document(
instance_id(),
serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_duplicate_a",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
}, {
"id": "acct_duplicate_b",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
}]
}),
)
.prepare_import_document(serde_json::json!({
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_duplicate_a",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
}, {
"id": "acct_duplicate_b",
"accountId": "chatgpt-shared",
"userId": "user-a",
"token": "token-shared-a"
}]
}))
.await
.expect_err("the same upstream user and workspace must stay unique");
assert_eq!(duplicate, CodexCredentialAdminError::InvalidInput);
@@ -629,43 +610,37 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
#[tokio::test]
async fn credential_bundle_and_auth_document_normalize_to_the_same_core_accounts() {
let bundle = import_service(unused_import_refresher())
.prepare_import_document(
instance_id(),
serde_json::json!({
"exported_at": "2026-07-03T15:46:38.717Z",
"proxies": [],
"accounts": [{
"name": "bundle@example.com",
"platform": "openai",
"type": "oauth",
"credentials": {
"at": "token-bundle",
"refresh_token": "refresh-bundle",
"chatgpt_account_id": "chatgpt-bundle",
"chatgpt_user_id": "user-chatgpt-bundle"
},
"concurrency": 3,
"priority": 50
}]
}),
)
.prepare_import_document(serde_json::json!({
"exported_at": "2026-07-03T15:46:38.717Z",
"proxies": [],
"accounts": [{
"name": "bundle@example.com",
"platform": "openai",
"type": "oauth",
"credentials": {
"at": "token-bundle",
"refresh_token": "refresh-bundle",
"chatgpt_account_id": "chatgpt-bundle",
"chatgpt_user_id": "user-chatgpt-bundle"
},
"concurrency": 3,
"priority": 50
}]
}))
.await
.expect("credential bundle import");
let auth_document = import_service(unused_import_refresher())
.prepare_import_document(
instance_id(),
serde_json::json!({
"accounts": [{
"type": "openai",
"access_token": "token-auth-document",
"refresh_token": "refresh-auth-document",
"chatgpt_account_id": "chatgpt-auth-document",
"chatgpt_user_id": "user-chatgpt-auth-document",
"email": "auth-document@example.com",
"label": "Auth document"
}]
}),
)
.prepare_import_document(serde_json::json!({
"accounts": [{
"type": "openai",
"access_token": "token-auth-document",
"refresh_token": "refresh-auth-document",
"chatgpt_account_id": "chatgpt-auth-document",
"chatgpt_user_id": "user-chatgpt-auth-document",
"email": "auth-document@example.com",
"label": "Auth document"
}]
}))
.await
.expect("auth document import");
@@ -738,7 +713,7 @@ async fn real_cpr_fixture_import_contract() {
runtime_policy(),
);
let prepared = service
.prepare_import_document(instance_id(), payload)
.prepare_import_document(payload)
.await
.expect("real CPR import");
@@ -1,21 +1,12 @@
use std::sync::Arc;
use std::time::SystemTime;
use chrono::{TimeZone, Utc};
use chrono::{TimeZone as _, Utc};
use futures::executor::block_on;
use gateway_core::engine::credential::{
AccountAvailability, AccountStateChange, CredentialRevision, ProviderAccountId,
ProviderAccountStore as _,
};
use gateway_core::routing::{InstanceHealth, ProviderInstance, ProviderKind};
use provider_openai::credential::{
CodexCredentialCatalogError, CodexCredentialCatalogService, ImportCodexOAuthCredential,
};
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{CodexCredentialCatalogError, CodexCredentialCatalogService};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{MemoryAccountStore, instance_id, loopback_origin_policy, profile, secret};
use crate::support::MemoryAccountStore;
fn wire_profile() -> CodexWireProfileState {
CodexWireProfileState::new(CodexWireProfile {
@@ -26,7 +17,7 @@ fn wire_profile() -> CodexWireProfileState {
os_type: "linux".to_owned(),
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
terminal: "catalog-contract".to_owned(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -34,30 +25,6 @@ fn wire_profile() -> CodexWireProfileState {
})
}
fn instance(base_url: &str) -> ProviderInstance {
ProviderInstance::new(
instance_id(),
ProviderKind::new("openai").expect("provider"),
base_url.to_owned(),
true,
InstanceHealth::Healthy,
)
}
async fn create(store: &Arc<MemoryAccountStore>, id: &str, token: &str) {
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: id.to_owned(),
provider_instance_id: instance_id().to_string(),
name: id.to_owned(),
secret: secret(token),
verified_account: profile(&format!("chatgpt-{id}")),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
}
fn service(store: &Arc<MemoryAccountStore>) -> CodexCredentialCatalogService {
CodexCredentialCatalogService::new(
store.repository(),
@@ -66,275 +33,79 @@ fn service(store: &Arc<MemoryAccountStore>) -> CodexCredentialCatalogService {
.no_proxy()
.build()
.expect("client"),
loopback_origin_policy(),
)
}
fn catalog(model: &str, display: &str) -> serde_json::Value {
serde_json::json!({
"models": [{
"slug": model,
"display_name": display,
"supported_in_api": true,
"supported_reasoning_levels": [{"effort": "medium"}],
"context_window": 128000
}]
})
}
fn account_id(value: &str) -> ProviderAccountId {
ProviderAccountId::new(value).expect("account id")
}
fn revision() -> CredentialRevision {
CredentialRevision::new(1).expect("revision")
}
#[tokio::test]
async fn realtime_catalog_builds_union_and_per_account_entitlements() {
let server = MockServer::start().await;
for (token, model) in [("token-one", "gpt-5.4"), ("token-two", "gpt-5.5")] {
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.and(header("authorization", format!("Bearer {token}")))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog(model, model)))
.expect(1)
.mount(&server)
.await;
}
#[test]
fn catalog_starts_without_a_snapshot_or_generation() {
let store = Arc::new(MemoryAccountStore::default());
create(&store, "acct_one", "token-one").await;
create(&store, "acct_two", "token-two").await;
let snapshot = service(&store)
.synchronize_instance(&instance(&format!("{}/backend-api", server.uri())))
.await
.expect("synchronize catalog");
assert_eq!(snapshot.models().len(), 2);
assert_eq!(
snapshot.account_models(&account_id("acct_one"), revision()),
Some(&["gpt-5.4".to_owned()][..])
);
assert_eq!(
snapshot.account_models(&account_id("acct_two"), revision()),
Some(&["gpt-5.5".to_owned()][..])
);
}
#[tokio::test]
async fn realtime_catalog_should_preserve_the_official_model_order() {
let server = MockServer::start().await;
let official_order = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.4"];
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": official_order
.iter()
.enumerate()
.map(|(index, model)| serde_json::json!({
"slug": model,
"display_name": model,
"priority": index + 1,
"supported_in_api": true,
"supported_reasoning_levels": [{"effort": "medium"}],
"context_window": 128000
}))
.collect::<Vec<_>>()
})))
.expect(1)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create(&store, "acct_order", "token-order").await;
let snapshot = service(&store)
.synchronize_instance(&instance(&format!("{}/backend-api", server.uri())))
.await
.expect("ordered catalog");
assert_eq!(
snapshot
.models()
.iter()
.map(|model| model.request_model().as_str())
.collect::<Vec<_>>(),
official_order
);
assert_eq!(
snapshot.account_models(&account_id("acct_order"), revision()),
Some(official_order.map(str::to_owned).as_slice())
);
}
#[tokio::test]
async fn catalog_is_reused_until_explicit_invalidation_or_etag_change() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog("gpt-5.4", "GPT-5.4")))
.expect(1)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create(&store, "acct_one", "token-one").await;
let service = service(&store);
let instance = instance(&format!("{}/backend-api", server.uri()));
let first = service
.synchronize_instance(&instance)
.await
.expect("first query");
let second = service
.synchronize_instance(&instance)
.await
.expect("cached query");
assert_eq!(first.observed_at(), second.observed_at());
assert!(service.cached(instance.id()).expect("cache read").is_some());
}
#[tokio::test]
async fn single_account_refresh_queries_only_that_account_and_updates_its_cache() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.and(header("authorization", "Bearer token-two"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog("gpt-5.5", "GPT-5.5")))
.expect(1)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create(&store, "acct_one", "token-one").await;
create(&store, "acct_two", "token-two").await;
let service = service(&store);
let instance = instance(&format!("{}/backend-api", server.uri()));
let selected = ProviderAccountId::new("acct_two").expect("account id");
let models = service
.synchronize_account(&instance, &selected)
.await
.expect("single account catalog");
assert_eq!(models, ["gpt-5.5"]);
assert_eq!(
service
.cached_account_models(instance.id(), &selected, revision())
.expect("cache read"),
Some(vec!["gpt-5.5".to_owned()])
);
assert_eq!(
service
.cached_account_models(instance.id(), &account_id("acct_one"), revision(),)
.expect("other cache read"),
None
);
}
#[tokio::test]
async fn invalidation_forces_a_new_realtime_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog("gpt-5.4", "GPT-5.4")))
.expect(2)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create(&store, "acct_one", "token-one").await;
let service = service(&store);
let instance = instance(&format!("{}/backend-api", server.uri()));
assert!(service.cached().expect("cache read").is_none());
assert_eq!(service.catalog_generation().get(), 0);
service
.synchronize_instance(&instance)
.await
.expect("first query");
assert_eq!(service.catalog_generation().get(), 1);
service.invalidate(instance.id()).expect("invalidate");
assert_eq!(service.catalog_generation().get(), 2);
service
.synchronize_instance(&instance)
.await
.expect("second query");
assert_eq!(service.catalog_generation().get(), 3);
}
#[tokio::test]
async fn response_etag_change_is_deduplicated_and_queued_for_refresh() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", "\"models-v1\"")
.set_body_json(catalog("gpt-5.4", "GPT-5.4")),
)
.expect(1)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create(&store, "acct_one", "token-one").await;
let service = service(&store);
let instance = instance(&format!("{}/backend-api", server.uri()));
service
.synchronize_instance(&instance)
.await
.expect("initial catalog");
assert!(
!service
.observe_response_etag(instance.id(), "\"models-v1\"")
.expect("same ETag")
);
assert!(
service
.observe_response_etag(instance.id(), "\"models-v2\"")
.expect("changed ETag")
);
assert!(
!service
.observe_response_etag(instance.id(), "\"models-v2\"")
.expect("duplicate ETag")
);
assert_eq!(
service.wait_for_etag_refresh().await,
vec![instance.id().clone()]
);
}
#[tokio::test]
async fn quota_exhausted_account_remains_eligible_for_catalog_discovery() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(catalog("gpt-5.4", "GPT-5.4")))
.expect(1)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create(&store, "acct_quota", "token-one").await;
let account = store.account("acct_quota").expect("created account");
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::QuotaExhausted,
reason: Some("quota exhausted".to_owned()),
cooldown_until: None,
observed_at: SystemTime::now(),
})
.await
.expect("mark quota exhausted");
let snapshot = service(&store)
.synchronize_instance(&instance(&format!("{}/backend-api", server.uri())))
.await
.expect("discover catalog through quota exhausted account");
assert_eq!(snapshot.models().len(), 1);
}
#[test]
fn catalog_without_schedulable_accounts_fails_before_network_io() {
fn catalog_without_openai_accounts_fails_before_network_io() {
let store = Arc::new(MemoryAccountStore::default());
let error =
block_on(service(&store).synchronize_instance(&instance("http://127.0.0.1:9/backend-api")))
.expect_err("empty account set");
let error = block_on(service(&store).synchronize()).expect_err("empty account set");
assert!(matches!(
error,
CodexCredentialCatalogError::NoEligibleCredential
));
}
#[tokio::test]
async fn missing_account_refresh_fails_before_network_io() {
let store = Arc::new(MemoryAccountStore::default());
let account = ProviderAccountId::new("acct_missing").expect("account id");
let error = service(&store)
.synchronize_account(&account)
.await
.expect_err("missing account");
assert!(matches!(
error,
CodexCredentialCatalogError::NoEligibleCredential
));
}
#[tokio::test]
async fn response_etag_change_is_deduplicated_and_queued_once() {
let store = Arc::new(MemoryAccountStore::default());
let service = service(&store);
assert!(
service
.observe_response_etag("\"models-v2\"")
.expect("new ETag")
);
assert!(
!service
.observe_response_etag("\"models-v2\"")
.expect("duplicate ETag")
);
tokio::time::timeout(
std::time::Duration::from_secs(1),
service.wait_for_etag_refresh(),
)
.await
.expect("queued ETag refresh");
}
#[test]
fn invalid_response_etag_is_rejected_without_touching_the_catalog() {
let store = Arc::new(MemoryAccountStore::default());
let service = service(&store);
let error = service
.observe_response_etag("invalid\netag")
.expect_err("invalid ETag");
assert!(matches!(error, CodexCredentialCatalogError::InvalidEtag));
assert!(service.cached().expect("cache read").is_none());
assert_eq!(service.catalog_generation().get(), 0);
}
@@ -12,24 +12,21 @@ use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::ProviderKind;
use provider_openai::credential::{
CodexAccountFailure, CodexCookiePolicy, CodexCredentialCatalogService, CodexCredentialCodec,
CodexCredentialQuotaService, CodexCredentialSelector, CredentialSelectionError,
ImportCodexOAuthCredential, SelectCodexCredential,
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::{CodexOriginPolicy, OfficialCodexOriginPolicy};
use secrecy::ExposeSecret;
use url::Url;
use crate::support::{
MemoryAccountStore, TestLeaseCoordinator, account_policy, instance_id, profile, secret,
};
use crate::support::{MemoryAccountStore, TestLeaseCoordinator, account_policy, profile, secret};
fn create_account(store: &Arc<MemoryAccountStore>, id: &str, token: &str) {
block_on(store.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: id.to_owned(),
provider_instance_id: instance_id().to_string(),
name: id.to_owned(),
secret: secret(token),
verified_account: profile(&format!("chatgpt-{id}")),
@@ -95,20 +92,18 @@ fn selector(
verified_at: Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
let origin: Arc<dyn CodexOriginPolicy> = Arc::new(OfficialCodexOriginPolicy);
let catalog = Arc::new(CodexCredentialCatalogService::new(
store.repository(),
profile.clone(),
http.clone(),
Arc::clone(&origin),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
store.repository(),
profile,
http,
origin,
));
CodexCredentialSelector::new(
ProviderKind::new("openai").expect("provider"),
store.repository(),
leases,
catalog,
@@ -231,7 +226,6 @@ fn selector_uses_frozen_global_account_policy_for_lease() {
let lease =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -252,7 +246,10 @@ fn selector_uses_frozen_global_account_policy_for_lease() {
.expect("runtime credential");
assert_eq!(runtime.installation_id, installation_id);
let requests = leases.requests.lock().expect("lease requests lock");
assert_eq!(requests[0].provider_instance_id(), &instance_id());
assert_eq!(
requests[0].provider_kind(),
&ProviderKind::new("openai").expect("provider")
);
assert_eq!(requests[0].account_id(), lease.account_id());
assert_eq!(
requests[0].credential_revision(),
@@ -276,7 +273,6 @@ fn selector_round_robin_cursor_advances_across_requests() {
for _ in 0..4 {
let attempt = round_robin_attempt();
let lease = block_on(selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &request_url,
attempt: &attempt,
@@ -303,7 +299,6 @@ fn selector_honors_attempt_local_account_exclusion() {
let lease =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -325,7 +320,6 @@ fn selector_uses_only_the_required_account() {
let lease =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -348,7 +342,6 @@ fn unavailable_required_account_never_falls_back() {
let error =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -373,7 +366,6 @@ fn selector_returns_capacity_error_when_every_redis_lease_is_busy() {
let error =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -398,7 +390,6 @@ fn credential_expired_failure_marks_unified_account_expired() {
let lease =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -427,7 +418,6 @@ fn identity_verification_failure_isolates_only_selected_account() {
let lease =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -466,7 +456,6 @@ fn cloudflare_challenge_backoff_escalates_and_success_resets_it() {
let lease =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -532,7 +521,6 @@ fn repeated_cloudflare_path_block_marks_only_the_affected_account_invalid() {
let lease =
block_on(
selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
@@ -572,7 +560,6 @@ fn cloudflare_challenge_expires_provider_owned_cookies_at_cooldown_boundary() {
Url::parse("https://chatgpt.com/backend-api/codex/responses").expect("request URL");
let first_attempt = attempt_with_required(BTreeSet::new(), Some(required.clone()));
let first = block_on(selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &request_url,
attempt: &first_attempt,
@@ -587,7 +574,6 @@ fn cloudflare_challenge_expires_provider_owned_cookies_at_cooldown_boundary() {
let second_attempt = attempt_with_required(BTreeSet::new(), Some(required));
let second = block_on(selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &request_url,
attempt: &second_attempt,
@@ -620,7 +606,6 @@ fn cloudflare_path_block_deletes_provider_owned_cookies() {
Url::parse("https://chatgpt.com/backend-api/codex/responses").expect("request URL");
let first_attempt = attempt_with_required(BTreeSet::new(), Some(required.clone()));
let first = block_on(selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &request_url,
attempt: &first_attempt,
@@ -635,7 +620,6 @@ fn cloudflare_path_block_deletes_provider_owned_cookies() {
let second_attempt = attempt_with_required(BTreeSet::new(), Some(required));
let second = block_on(selector.select(&SelectCodexCredential {
provider_instance_id: &instance_id(),
upstream_model: "gpt-5.4",
request_url: &request_url,
attempt: &second_attempt,
@@ -24,7 +24,7 @@ use secrecy::{ExposeSecret, SecretString};
use sha2::{Digest as _, Sha256};
use url::Url;
use crate::support::{MemoryAccountStore, instance_id, profile, runtime_policy, secret};
use crate::support::{MemoryAccountStore, profile, runtime_policy, secret};
#[derive(Default)]
struct PendingStore {
@@ -45,7 +45,6 @@ impl CodexOAuthPendingStore for PendingStore {
flow_id: pending.flow_id().to_owned(),
owner_ref: pending.owner_ref().to_owned(),
started_request_ref: pending.started_request_ref().to_owned(),
provider_instance_id: pending.provider_instance_id().to_owned(),
name: pending.name().to_owned(),
expires_at: pending.expires_at(),
state: pending.state().clone(),
@@ -176,7 +175,6 @@ fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
Revision::new(1).expect("revision"),
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Create {
provider_instance_id: instance_id(),
name: "OAuth Account".to_owned(),
},
AuthorizationOwnerBinding::from_context(&context),
@@ -189,7 +187,6 @@ fn reauthorization_mutation(request_id: &str, account_id: &str) -> PendingAuthor
Revision::new(1).expect("revision"),
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Reauthorize {
provider_instance_id: instance_id(),
account_id: ProviderAccountId::new(account_id).expect("account id"),
expected_credential_revision: Revision::new(1).expect("revision"),
},
@@ -317,7 +314,6 @@ async fn reauthorization_binds_account_revision_and_returns_only_prepared_rotati
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_oauth_reauth".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "reauthorize".to_owned(),
secret: secret("old-oauth-access"),
verified_account: profile("chatgpt-oauth"),
@@ -414,7 +410,6 @@ async fn reauthorization_rejects_identity_rebinding() {
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_oauth_rebind".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "reauthorize".to_owned(),
secret: secret("old-oauth-access"),
verified_account: profile("chatgpt-oauth"),
@@ -1,22 +1,20 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use std::time::SystemTime;
use chrono::{TimeZone, Utc};
use chrono::{TimeZone as _, Utc};
use futures::future::join_all;
use gateway_core::engine::credential::{
AccountAvailability, AccountStateChange, ProviderAccountStore,
AccountAvailability, OpaqueProviderData, ProviderAccountStore as _, QuotaObservation,
QuotaWriteOutcome,
};
use gateway_core::routing::{InstanceHealth, ProviderInstance, ProviderKind};
use provider_openai::credential::{
CodexCredentialQuotaService, CodexQuotaSyncSummary, CodexQuotaWindowKind,
ImportCodexOAuthCredential, parse_codex_quota_usage,
CodexCredentialQuotaService, CodexQuotaWindowKind, ImportCodexOAuthCredential,
parse_codex_quota_usage,
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{MemoryAccountStore, instance_id, loopback_origin_policy, profile, secret};
use crate::support::{MemoryAccountStore, profile, secret};
#[test]
fn parser_extracts_dynamic_windows_without_a_fixed_database_shape() {
@@ -32,6 +30,7 @@ fn parser_extracts_dynamic_windows_without_a_fixed_database_shape() {
}]
}))
.expect("valid dynamic quota");
assert_eq!(fact.remaining_percent(), Some(20));
assert_eq!(
fact.resets_at().map(|value| value.timestamp()),
@@ -49,6 +48,7 @@ fn parser_treats_any_confirmed_provider_limit_as_exhausted() {
}]
}))
.expect("valid quota");
assert!(fact.exhausted());
}
@@ -63,6 +63,7 @@ fn parser_does_not_infer_exhaustion_from_unknown_credit_fields() {
}
}))
.expect("recognized credits object");
assert!(!fact.exhausted());
}
@@ -108,6 +109,7 @@ fn parser_rejects_wrong_known_field_type_without_echoing_body() {
"rate_limit": {"allowed": marker}
}))
.expect_err("known field type must be strict");
assert!(!format!("{error:?} {error}").contains(marker));
}
@@ -125,7 +127,7 @@ fn wire_profile() -> CodexWireProfileState {
os_type: "linux".to_owned(),
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
terminal: "quota-contract".to_owned(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -141,24 +143,26 @@ fn quota_service(store: &Arc<MemoryAccountStore>) -> CodexCredentialQuotaService
.no_proxy()
.build()
.expect("client"),
loopback_origin_policy(),
)
}
async fn create_account(store: &Arc<MemoryAccountStore>, account_id: &str) {
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
name: account_id.to_owned(),
secret: secret(&format!("token-{account_id}")),
verified_account: profile(&format!("chatgpt-{account_id}")),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
}
#[tokio::test]
async fn concurrent_cold_scheduling_hydration_reads_quota_once() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_hydration".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "hydration".to_owned(),
secret: secret("hydration-token"),
verified_account: profile("chatgpt-acct_hydration"),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
create_account(&store, "acct_hydration").await;
let account = store.account("acct_hydration").expect("created account");
let service = quota_service(&store);
@@ -168,8 +172,10 @@ async fn concurrent_cold_scheduling_hydration_reads_quota_once() {
}
#[tokio::test]
async fn quota_service_stores_raw_provider_json_and_projects_common_state() {
let server = MockServer::start().await;
async fn persisted_provider_quota_projects_dynamic_windows_without_network_io() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_quota").await;
let account = store.account("acct_quota").expect("created account");
let raw = json!({
"rate_limit": {
"allowed": true,
@@ -182,8 +188,7 @@ async fn quota_service_stores_raw_provider_json_and_projects_common_state() {
"used_percent": 42,
"reset_at": 1_900_604_800,
"limit_window_seconds": 604_800
},
"provider_specific_future_window": {"used": "opaque"}
}
},
"additional_rate_limits": null,
"spend_control": {
@@ -195,177 +200,39 @@ async fn quota_service_stores_raw_provider_json_and_projects_common_state() {
},
"provider_specific_root": {"opaque": [1, 2, 3]}
});
Mock::given(method("GET"))
.and(path("/backend-api/wham/usage"))
.and(header("authorization", "Bearer quota-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(&raw))
.expect(2)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_quota".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "quota".to_owned(),
secret: secret("quota-token"),
verified_account: profile("chatgpt-acct_quota"),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
let outcome = store
.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: Some(OpaqueProviderData::new(
raw.as_object().expect("quota object").clone(),
)),
observed_at: Some(SystemTime::now()),
})
.await;
let service = quota_service(&store);
let instance = ProviderInstance::new(
instance_id(),
ProviderKind::new("openai").expect("provider"),
format!("{}/backend-api", server.uri()),
true,
InstanceHealth::Healthy,
);
let summary = service
.synchronize_instance(&instance)
.await
.expect("synchronize quota");
assert_eq!(summary.updated, 1, "unexpected quota summary: {summary:?}");
let account = store.account("acct_quota").expect("account");
assert_eq!(account.availability(), AccountAvailability::Ready);
let parsed = service
.expect("persist quota");
assert_eq!(outcome, QuotaWriteOutcome::Updated);
let snapshot = quota_service(&store)
.read_account(account.id())
.await
.expect("read quota")
.expect("quota snapshot");
assert_eq!(parsed.account_id(), account.id());
assert_eq!(parsed.credential_revision(), account.revision());
assert_eq!(parsed.windows().len(), 3);
assert_eq!(parsed.windows()[0].kind(), CodexQuotaWindowKind::ShortTerm);
assert_eq!(parsed.windows()[0].window_seconds(), Some(18_000));
assert_eq!(parsed.windows()[0].source(), "core");
assert_eq!(parsed.windows()[1].kind(), CodexQuotaWindowKind::Weekly);
assert_eq!(parsed.windows()[1].window_seconds(), Some(604_800));
assert_eq!(parsed.windows()[2].kind(), CodexQuotaWindowKind::Monthly);
assert_eq!(parsed.windows()[2].source(), "spend_control");
let refreshed = service
.refresh_account(&instance, account.id())
.await
.expect("refresh one account");
assert_eq!(refreshed.account_id(), account.id());
assert_eq!(refreshed.windows()[0].used_percent(), Some(37.0));
let observation = store
.get_quotas(std::slice::from_ref(account.id()))
.await
.expect("quota read")
.pop()
.expect("quota observation");
assert_eq!(snapshot.windows().len(), 3);
assert_eq!(
observation
.quota
.expect("raw quota")
.expose_to_provider()
.get("provider_specific_root"),
raw.get("provider_specific_root")
);
}
#[tokio::test]
async fn quota_refresh_success_must_not_clear_newer_future_cooldown() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/usage"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(100))
.set_body_json(json!({
"rate_limit": {
"allowed": true,
"primary_window": {"used_percent": 10}
}
})),
)
.expect(1)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_quota_cooldown".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "quota cooldown".to_owned(),
secret: secret("quota-cooldown-token"),
verified_account: profile("chatgpt-acct_quota_cooldown"),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let account = store.account("acct_quota_cooldown").expect("quota account");
let service = quota_service(&store);
let instance = ProviderInstance::new(
instance_id(),
ProviderKind::new("openai").expect("provider"),
format!("{}/backend-api", server.uri()),
true,
InstanceHealth::Healthy,
);
let cooldown_until = SystemTime::now() + Duration::from_secs(3_600);
let synchronize = service.synchronize_instance(&instance);
let apply_newer_cooldown = async {
tokio::time::sleep(Duration::from_millis(20)).await;
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::Cooldown,
reason: Some("model_request_rate_limited".to_owned()),
cooldown_until: Some(cooldown_until),
observed_at: SystemTime::now(),
})
.await
.expect("apply newer cooldown");
};
let (summary, ()) = tokio::join!(synchronize, apply_newer_cooldown);
assert_eq!(summary.expect("quota synchronization").updated, 1);
let refreshed = store
.account("acct_quota_cooldown")
.expect("refreshed account");
assert_eq!(refreshed.availability(), AccountAvailability::Cooldown);
assert_eq!(refreshed.cooldown_until(), Some(cooldown_until));
}
#[tokio::test]
async fn non_exhausted_quota_refresh_should_restore_quota_exhausted_account() {
let (summary, availability, cooldown_until) = synchronize_account_from_state(
"acct_quota_recovered",
AccountAvailability::QuotaExhausted,
None,
ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"primary_window": {"used_percent": 10}
}
})),
)
.await;
assert_eq!(
(summary.updated, availability, cooldown_until),
(1, AccountAvailability::Ready, None)
snapshot.windows()[0].kind(),
CodexQuotaWindowKind::ShortTerm
);
assert_eq!(snapshot.windows()[0].window_seconds(), Some(18_000));
assert_eq!(snapshot.windows()[1].kind(), CodexQuotaWindowKind::Weekly);
assert_eq!(snapshot.windows()[2].kind(), CodexQuotaWindowKind::Monthly);
}
#[tokio::test]
async fn passive_rate_limit_headers_update_quota_and_account_state_with_revision_fence() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_passive_quota".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "passive quota".to_owned(),
secret: secret("passive-quota-token"),
verified_account: profile("chatgpt-acct_passive_quota"),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
create_account(&store, "acct_passive_quota").await;
let account = store.account("acct_passive_quota").expect("account");
let service = quota_service(&store);
let reset_at = 1_900_000_000_i64;
@@ -390,142 +257,33 @@ async fn passive_rate_limit_headers_update_quota_and_account_state_with_revision
.account("acct_passive_quota")
.expect("current account");
assert_eq!(current.availability(), AccountAvailability::QuotaExhausted);
let observation = store
.get_quotas(std::slice::from_ref(account.id()))
let snapshot = service
.read_account(account.id())
.await
.expect("quota")
.pop()
.expect("quota observation");
let quota = serde_json::Value::Object(
observation
.quota
.expect("quota JSON")
.expose_to_provider()
.clone(),
);
.expect("read quota")
.expect("quota snapshot");
assert_eq!(snapshot.windows()[0].window_seconds(), Some(18_000));
assert_eq!(
quota
.pointer("/rate_limit/primary_window/limit_window_seconds")
.and_then(serde_json::Value::as_u64),
Some(18_000)
);
assert_eq!(
quota
.pointer("/rate_limit/primary_window/reset_at")
.and_then(serde_json::Value::as_i64),
snapshot.windows()[0]
.reset_at()
.map(|value| value.timestamp()),
Some(reset_at)
);
}
#[tokio::test]
async fn exhausted_quota_refresh_should_upgrade_future_cooldown() {
let cooldown_until = SystemTime::now() + Duration::from_secs(3_600);
let (summary, availability, projected_cooldown) = synchronize_account_from_state(
"acct_quota_exhausted",
AccountAvailability::Cooldown,
Some(cooldown_until),
ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": false,
"limit_reached": true,
"primary_window": {"used_percent": 100}
}
})),
)
.await;
assert_eq!(
(summary.exhausted, availability, projected_cooldown),
(1, AccountAvailability::QuotaExhausted, None)
);
}
#[tokio::test]
async fn structured_deactivated_workspace_rejection_should_ban_account() {
let (summary, availability, cooldown_until) = synchronize_account_from_state(
"acct_quota_deactivated",
AccountAvailability::Ready,
None,
ResponseTemplate::new(402)
.set_body_json(json!({"detail": {"code": "deactivated_workspace"}})),
)
.await;
assert_eq!(
(summary.invalid, availability, cooldown_until),
(1, AccountAvailability::Banned, None)
);
}
#[tokio::test]
async fn generic_payment_required_rejection_should_exhaust_quota() {
let (summary, availability, cooldown_until) = synchronize_account_from_state(
"acct_quota_payment_required",
AccountAvailability::Ready,
None,
ResponseTemplate::new(402).set_body_json(json!({"detail": {"code": "payment_required"}})),
)
.await;
assert_eq!(
(summary.exhausted, availability, cooldown_until),
(1, AccountAvailability::QuotaExhausted, None)
);
}
async fn synchronize_account_from_state(
account_id: &str,
availability: AccountAvailability,
cooldown_until: Option<SystemTime>,
response: ResponseTemplate,
) -> (
CodexQuotaSyncSummary,
AccountAvailability,
Option<SystemTime>,
) {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/usage"))
.respond_with(response)
.expect(1)
.mount(&server)
.await;
async fn synchronize_without_accounts_is_a_noop_before_network_io() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
provider_instance_id: instance_id().to_string(),
name: account_id.to_owned(),
secret: secret(&format!("token-{account_id}")),
verified_account: profile(&format!("chatgpt-{account_id}")),
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let account = store.account(account_id).expect("quota account");
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
availability,
reason: Some("previous_state".to_owned()),
cooldown_until,
observed_at: SystemTime::now(),
})
let summary = quota_service(&store)
.synchronize()
.await
.expect("apply initial account state");
let service = quota_service(&store);
let instance = ProviderInstance::new(
instance_id(),
ProviderKind::new("openai").expect("provider"),
format!("{}/backend-api", server.uri()),
true,
InstanceHealth::Healthy,
);
let summary = service
.synchronize_instance(&instance)
.await
.expect("synchronize quota");
let account = store.account(account_id).expect("refreshed quota account");
(summary, account.availability(), account.cooldown_until())
.expect("empty synchronization");
assert_eq!(summary.updated, 0);
assert_eq!(summary.exhausted, 0);
assert_eq!(summary.invalid, 0);
assert_eq!(summary.cooldown, 0);
assert_eq!(summary.transient, 0);
assert_eq!(summary.stale, 0);
}
@@ -17,7 +17,7 @@ use provider_openai::credential::{
};
use secrecy::{ExposeSecret, SecretString};
use crate::support::{MemoryAccountStore, instance_id, profile, runtime_policy, secret};
use crate::support::{MemoryAccountStore, profile, runtime_policy, secret};
struct Refresher {
outcomes: Mutex<VecDeque<Result<TokenPair, RefreshFailure>>>,
@@ -177,7 +177,7 @@ impl CodexAccountIdentityVerifier for SignedOnlyIdentity {
impl ProviderLeasePort for RefreshLeases {
fn load_state<'a>(
&'a self,
_: &'a gateway_core::routing::ProviderInstanceId,
_: &'a gateway_core::routing::ProviderKind,
_: &'a [gateway_core::engine::credential::ProviderAccountId],
) -> BoxFuture<
'a,
@@ -227,7 +227,6 @@ async fn setup(
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_refresh".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "refresh".to_owned(),
secret: secret("old-access"),
verified_account: profile("chatgpt-acct_refresh"),
@@ -361,7 +360,6 @@ async fn unavailable_usage_preserves_rotated_tokens_and_persists_backoff() {
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_signed_only".to_owned(),
provider_instance_id: instance_id().to_string(),
name: "signed only".to_owned(),
secret: secret("old-access"),
verified_account: account_profile,
@@ -535,7 +533,6 @@ async fn malformed_account_refresh_does_not_stop_later_accounts() {
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
provider_instance_id: instance_id().to_string(),
name: account_id.to_owned(),
secret: secret(account_id),
verified_account: profile(&format!("chatgpt-{account_id}")),
@@ -1,83 +0,0 @@
use gateway_core::routing::{InstanceHealth, ProviderInstance, ProviderInstanceId, ProviderKind};
use provider_openai::{
CodexProviderConfigError, CodexProviderInstanceConfig, CodexProviderTransport,
OFFICIAL_CODEX_BASE_PATH, OfficialCodexOriginPolicy,
};
use crate::support::LoopbackCodexOriginPolicy;
fn instance(provider: &str, base_url: &str) -> ProviderInstance {
ProviderInstance::new(
ProviderInstanceId::new("inst_openai").expect("instance id"),
ProviderKind::new(provider).expect("provider"),
base_url.to_owned(),
true,
InstanceHealth::Healthy,
)
}
#[test]
fn official_instance_requires_exact_https_origin_and_base_path() {
let compiled = CodexProviderInstanceConfig::from_snapshot(
&instance("openai", "https://chatgpt.com/backend-api/"),
&OfficialCodexOriginPolicy,
)
.expect("official endpoint");
assert_eq!(compiled.base_url().path(), OFFICIAL_CODEX_BASE_PATH);
assert_eq!(
compiled.transport(),
CodexProviderTransport::PreferWebSocket
);
}
#[test]
fn official_policy_rejects_lookalike_private_and_metadata_origins() {
for endpoint in [
"https://api.chatgpt.com/backend-api",
"https://chatgpt.com.evil.invalid/backend-api",
"https://chatgpt.com:8443/backend-api",
"https://chatgpt.com/other",
"https://169.254.169.254/backend-api",
"https://10.0.0.1/backend-api",
"http://chatgpt.com/backend-api",
] {
assert_eq!(
CodexProviderInstanceConfig::from_snapshot(
&instance("openai", endpoint),
&OfficialCodexOriginPolicy,
),
Err(CodexProviderConfigError::UnsafeBaseUrl),
"endpoint must fail closed: {endpoint}"
);
}
}
#[test]
fn loopback_policy_requires_explicit_numeric_loopback_host() {
let numeric = instance("openai", "http://127.0.0.1:43123/backend-api");
assert_eq!(
CodexProviderInstanceConfig::from_snapshot(&numeric, &OfficialCodexOriginPolicy),
Err(CodexProviderConfigError::UnsafeBaseUrl)
);
assert!(
CodexProviderInstanceConfig::from_snapshot(&numeric, &LoopbackCodexOriginPolicy).is_ok()
);
assert_eq!(
CodexProviderInstanceConfig::from_snapshot(
&instance("openai", "http://localhost:43123/backend-api"),
&LoopbackCodexOriginPolicy,
),
Err(CodexProviderConfigError::UnsafeBaseUrl)
);
}
#[test]
fn non_codex_instance_is_rejected_before_transport_initialization() {
assert_eq!(
CodexProviderInstanceConfig::from_snapshot(
&instance("xai", "https://chatgpt.com/backend-api"),
&OfficialCodexOriginPolicy,
),
Err(CodexProviderConfigError::ProviderMismatch)
);
}
File diff suppressed because it is too large Load Diff
@@ -1,2 +1 @@
mod config;
mod contract;
@@ -20,14 +20,12 @@ use gateway_core::provider_ports::{
ProviderRuntimePolicyPort, ProviderSchedulingLeaseRequest, ProviderSchedulingState,
ProviderStoreError,
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use provider_openai::CodexOriginPolicy;
use gateway_core::routing::ProviderKind;
use provider_openai::credential::{
CodexAccountProfile, CodexCredentialAdmin, CodexCredentialRepository, CodexOAuthSecret,
ImportCodexOAuthCredential,
};
use secrecy::SecretString;
use url::{Host, Url};
#[derive(Clone)]
struct StoredAccount {
@@ -110,16 +108,16 @@ impl ProviderAccountStore for MemoryAccountStore {
.collect())
}
async fn list_for_instance(
async fn list_for_provider(
&self,
instance: &ProviderInstanceId,
provider: &ProviderKind,
) -> Result<Vec<ProviderAccount>, StoreError> {
Ok(self
.accounts
.lock()
.expect("account store lock")
.values()
.filter(|stored| stored.account.instance() == instance)
.filter(|stored| stored.account.provider() == provider)
.map(|stored| stored.account.clone())
.collect())
}
@@ -294,21 +292,6 @@ const fn store_error(kind: StoreErrorKind) -> StoreError {
StoreError::new(kind)
}
#[derive(Debug)]
pub(crate) struct LoopbackCodexOriginPolicy;
impl CodexOriginPolicy for LoopbackCodexOriginPolicy {
fn allows(&self, url: &Url) -> bool {
url.scheme() == "http"
&& matches!(url.host(), Some(Host::Ipv4(host)) if host.is_loopback())
&& url.port().is_some()
}
}
pub(crate) fn loopback_origin_policy() -> Arc<dyn CodexOriginPolicy> {
Arc::new(LoopbackCodexOriginPolicy)
}
struct AccountRebuild {
revision: CredentialRevision,
enabled: bool,
@@ -330,7 +313,6 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid
});
ProviderAccount::new(
current.id().clone(),
current.instance().clone(),
current.provider().clone(),
name,
current.upstream_user_id().to_owned(),
@@ -360,7 +342,7 @@ pub(crate) struct TestLeaseCoordinator {
impl ProviderLeasePort for TestLeaseCoordinator {
fn load_state<'a>(
&'a self,
_provider_instance_id: &'a ProviderInstanceId,
_provider_kind: &'a ProviderKind,
accounts: &'a [ProviderAccountId],
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
Box::pin(async move {
@@ -460,14 +442,9 @@ pub(crate) fn account_policy() -> gateway_core::engine::credential::AccountSelec
)
}
pub(crate) fn instance_id() -> ProviderInstanceId {
ProviderInstanceId::new("inst_openai_primary").expect("instance id")
}
pub(crate) fn codex_account(id: &str) -> ProviderAccount {
ProviderAccount::new(
ProviderAccountId::new(id).expect("account id"),
instance_id(),
ProviderKind::new("openai").expect("provider"),
id.to_owned(),
format!("user-chatgpt-{id}"),
+10 -60
View File
@@ -42,7 +42,7 @@ use gateway_core::provider_ports::{
OAuthPendingTakeOutcome, ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError,
ProviderStoreErrorKind,
};
use gateway_core::routing::{ProviderInstanceId, ProviderKind, UpstreamModelId};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use serde::Deserialize;
use serde_json::{Map, Number, Value};
use sha2::{Digest as _, Sha256};
@@ -62,7 +62,7 @@ use crate::credential::{
};
use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown};
const PENDING_SCHEMA_VERSION: u64 = 1;
const PENDING_SCHEMA_VERSION: u64 = 2;
const PENDING_TTL: TimeDelta = TimeDelta::minutes(30);
const MAX_PENDING_TEXT_BYTES: usize = 512;
@@ -325,7 +325,6 @@ impl ProviderAdmin for XaiAdminProvider {
Uuid::now_v7().simple()
))
.map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?,
provider_instance_id: command.provider_instance_id.clone(),
name,
email,
upstream_account_id: None,
@@ -340,7 +339,6 @@ impl ProviderAdmin for XaiAdminProvider {
}
Ok(PreparedCredentialImport {
provider_kind: self.provider_kind.clone(),
provider_instance_id: command.provider_instance_id,
credentials,
})
}
@@ -353,7 +351,6 @@ impl ProviderAdmin for XaiAdminProvider {
return Err(provider_error(ProviderAdminErrorKind::Invalid));
}
if let AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} = pending.target()
@@ -365,9 +362,7 @@ impl ProviderAdmin for XaiAdminProvider {
.load_credential(account_id, revision)
.await
.map_err(map_store_error)?;
if current.account.provider() != &self.provider_kind
|| current.account.instance() != provider_instance_id
{
if current.account.provider() != &self.provider_kind {
return Err(provider_error(ProviderAdminErrorKind::NotFound));
}
}
@@ -410,10 +405,7 @@ impl ProviderAdmin for XaiAdminProvider {
.await
.map_err(map_provider_store_error)?;
let credential = match stored.mutation.target() {
AuthorizationMutationTarget::Create {
provider_instance_id,
name,
} => {
AuthorizationMutationTarget::Create { name } => {
let prepared = GrokCredentialAdmin
.prepare_verified_account(
&VerifiedGrokAccount {
@@ -422,7 +414,6 @@ impl ProviderAdmin for XaiAdminProvider {
Uuid::now_v7().simple()
))
.map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?,
provider_instance_id: provider_instance_id.clone(),
name: name.clone(),
email: None,
upstream_account_id: None,
@@ -436,7 +427,6 @@ impl ProviderAdmin for XaiAdminProvider {
PreparedAuthorizationCredential::Create(prepared_create(prepared, Utc::now())?)
}
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} => {
@@ -447,15 +437,12 @@ impl ProviderAdmin for XaiAdminProvider {
.load_credential(account_id, revision)
.await
.map_err(map_store_error)?;
if current.account.provider() != &self.provider_kind
|| current.account.instance() != provider_instance_id
{
if current.account.provider() != &self.provider_kind {
return Err(provider_error(ProviderAdminErrorKind::NotFound));
}
let prepared = verified_rotation(current, tokens, policy)?;
PreparedAuthorizationCredential::Reauthorize(prepared_rotation(
prepared,
provider_instance_id.clone(),
self.provider_kind.clone(),
)?)
}
@@ -524,11 +511,7 @@ impl ProviderAdmin for XaiAdminProvider {
.await
.map_err(map_provider_store_error)?;
let prepared = verified_rotation(current, tokens, policy)?;
prepared_rotation(
prepared,
command.account.provider_instance_id,
command.account.provider_kind,
)
prepared_rotation(prepared, command.account.provider_kind)
}
async fn prepare_refresh(
@@ -549,11 +532,7 @@ impl ProviderAdmin for XaiAdminProvider {
.prepare_manual_refresh(&account_id, revision)
.await
.map_err(map_refresh_error)?;
prepared_rotation(
prepared,
command.account.provider_instance_id,
command.account.provider_kind,
)
prepared_rotation(prepared, command.account.provider_kind)
}
async fn quota(
@@ -735,7 +714,6 @@ fn prepared_create(
} = prepared;
Ok(PreparedCredentialCreate {
account_id: account.id().clone(),
provider_instance_id: account.instance().clone(),
provider_kind: account.provider().clone(),
name: account.name().to_owned(),
email: account.email().map(str::to_owned),
@@ -756,7 +734,6 @@ fn prepared_create(
fn prepared_rotation(
prepared: PreparedGrokCredentialRotation,
provider_instance_id: ProviderInstanceId,
provider_kind: ProviderKind,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
let (profile, credential, guard) = prepared.into_parts();
@@ -775,7 +752,6 @@ fn prepared_rotation(
Ok(PreparedCredentialRotation::new(
PreparedCredentialRotationFacts {
account_id,
provider_instance_id,
provider_kind,
expected_credential_revision: Revision::new(expected_revision.get())
.map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?,
@@ -812,7 +788,6 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
Ok(ProviderAccount::new(
account_id,
account.provider_instance_id.clone(),
account.provider_kind.clone(),
account.name.clone(),
account.upstream_user_id.clone(),
@@ -837,7 +812,6 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) -> bool {
account.id().as_str() == record.id
&& account.instance() == &record.provider_instance_id
&& account.provider() == &record.provider_kind
&& account.revision().get() == record.credential_revision.get()
&& account.name() == record.name
@@ -1029,11 +1003,9 @@ struct PendingMutationDocument {
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum PendingTargetDocument {
Create {
provider_instance_id: String,
name: String,
},
Reauthorize {
provider_instance_id: String,
account_id: String,
expected_credential_revision: u64,
},
@@ -1101,27 +1073,15 @@ fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value
fn encode_target(target: &AuthorizationMutationTarget) -> Map<String, Value> {
let mut document = Map::new();
match target {
AuthorizationMutationTarget::Create {
provider_instance_id,
name,
} => {
AuthorizationMutationTarget::Create { name } => {
document.insert("kind".to_owned(), Value::String("create".to_owned()));
document.insert(
"provider_instance_id".to_owned(),
Value::String(provider_instance_id.to_string()),
);
document.insert("name".to_owned(), Value::String(name.clone()));
}
AuthorizationMutationTarget::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} => {
document.insert("kind".to_owned(), Value::String("reauthorize".to_owned()));
document.insert(
"provider_instance_id".to_owned(),
Value::String(provider_instance_id.to_string()),
);
document.insert(
"account_id".to_owned(),
Value::String(account_id.to_string()),
@@ -1194,21 +1154,11 @@ fn decode_mutation(
let provider_kind = ProviderKind::new(document.provider_kind)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
let target = match document.target {
PendingTargetDocument::Create {
provider_instance_id,
name,
} => AuthorizationMutationTarget::Create {
provider_instance_id: ProviderInstanceId::new(provider_instance_id)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?,
name,
},
PendingTargetDocument::Create { name } => AuthorizationMutationTarget::Create { name },
PendingTargetDocument::Reauthorize {
provider_instance_id,
account_id,
expected_credential_revision,
} => AuthorizationMutationTarget::Reauthorize {
provider_instance_id: ProviderInstanceId::new(provider_instance_id)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?,
account_id: ProviderAccountId::new(account_id)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?,
expected_credential_revision: Revision::new(expected_credential_revision)
@@ -1410,7 +1360,7 @@ fn map_quota_error(error: GrokQuotaError) -> ProviderAdminError {
fn map_catalog_error(error: GrokCredentialCatalogError) -> ProviderAdminError {
use GrokCredentialCatalogError as Error;
provider_error(match error {
Error::InvalidInstance | Error::InvalidCredentialData | Error::ConflictingModelFacts => {
Error::InvalidCredentialData | Error::ConflictingModelFacts => {
ProviderAdminErrorKind::Invalid
}
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
@@ -14,7 +14,7 @@ use gateway_core::engine::credential::{
};
use gateway_core::engine::provider::ProviderCatalogGeneration;
use gateway_core::provider_ports::{ProviderCatalogCacheKey, ProviderCatalogCachePort};
use gateway_core::routing::{ConfigRevision, ProviderInstance, ProviderKind};
use gateway_core::routing::{ConfigRevision, ProviderKind};
use tokio::sync::Mutex;
use super::repository::{GrokCredentialRepository, LoadedGrokCredential};
@@ -22,9 +22,9 @@ use super::types::{GrokCredentialAvailability, UpdateGrokCredentialState};
use crate::XaiWireProfileState;
use crate::transport::catalog::{MAX_CATALOG_MODELS, valid_model_slug, validate_etag};
use crate::{
GrokBillingClient, GrokBillingTransport, GrokBuildProvider, GrokCatalogModel,
GrokModelCatalogClient, GrokModelCatalogSession, GrokModelCatalogSnapshot,
GrokModelCatalogTransport, SecretValue, parse_grok_billing,
GrokBillingClient, GrokBillingTransport, GrokCatalogModel, GrokModelCatalogClient,
GrokModelCatalogSession, GrokModelCatalogSnapshot, GrokModelCatalogTransport, SecretValue,
parse_grok_billing,
};
const MAX_CONCURRENT_CATALOG_REQUESTS: usize = 8;
@@ -805,11 +805,10 @@ pub enum GrokCatalogCacheError {
InvalidData,
}
/// 一次 instance 同步得到的账号目录和严格模型并集。
/// 一次 Provider 同步得到的账号目录和严格模型并集。
#[derive(Clone, Debug)]
pub struct GrokCredentialCatalogSnapshot {
config_revision: ConfigRevision,
provider_instance_id: String,
observed_at: DateTime<Utc>,
accounts: Vec<GrokAccountCatalog>,
models: Vec<GrokCatalogModel>,
@@ -821,11 +820,6 @@ impl GrokCredentialCatalogSnapshot {
self.config_revision
}
#[must_use]
pub fn provider_instance_id(&self) -> &str {
&self.provider_instance_id
}
#[must_use]
pub const fn observed_at(&self) -> DateTime<Utc> {
self.observed_at
@@ -844,8 +838,6 @@ impl GrokCredentialCatalogSnapshot {
#[derive(Debug, thiserror::Error)]
pub enum GrokCredentialCatalogError {
#[error("Grok model catalog instance configuration is invalid")]
InvalidInstance,
#[error("Grok model catalog has no eligible OAuth account")]
NoEligibleCredential,
#[error("Grok model catalog credential snapshot is stale")]
@@ -874,7 +866,7 @@ pub struct GrokCredentialCatalogService {
#[derive(Default)]
struct PublishedCatalogState {
generation: u64,
models: BTreeMap<String, Vec<GrokCatalogModel>>,
models: Vec<GrokCatalogModel>,
}
impl GrokCredentialCatalogService {
@@ -982,15 +974,13 @@ impl GrokCredentialCatalogService {
}
/// 实时拉取全部 eligible OAuth account;结果只写可重建 TTL cache,不写 PostgreSQL。
pub async fn synchronize_instance(
pub async fn synchronize(
&self,
instance: &ProviderInstance,
config_revision: ConfigRevision,
) -> Result<GrokCredentialCatalogSnapshot, GrokCredentialCatalogError> {
let catalog = self.fetch_and_cache(instance).await?;
let catalog = self.fetch_and_cache().await?;
Ok(GrokCredentialCatalogSnapshot {
config_revision,
provider_instance_id: catalog.provider_instance_id,
observed_at: catalog.observed_at,
accounts: catalog.accounts,
models: catalog.models,
@@ -998,22 +988,14 @@ impl GrokCredentialCatalogService {
}
/// Provider Registry 构建 RuntimeSnapshot 时使用的实时能力目录。
pub async fn query_instance_models(
&self,
instance: &ProviderInstance,
) -> Result<Vec<GrokCatalogModel>, GrokCredentialCatalogError> {
Ok(self.fetch_and_cache(instance).await?.models)
pub async fn query_models(&self) -> Result<Vec<GrokCatalogModel>, GrokCredentialCatalogError> {
Ok(self.fetch_and_cache().await?.models)
}
async fn fetch_and_cache(
&self,
instance: &ProviderInstance,
) -> Result<FetchedInstanceCatalog, GrokCredentialCatalogError> {
let instance_config = GrokBuildProvider::validate_instance(instance)
.map_err(|_| GrokCredentialCatalogError::InvalidInstance)?;
async fn fetch_and_cache(&self) -> Result<FetchedProviderCatalog, GrokCredentialCatalogError> {
let candidates = self
.repository
.list_loaded_for_instance(instance_config.id())
.list_loaded_for_provider()
.await
.map_err(|_| GrokCredentialCatalogError::Store)?
.into_iter()
@@ -1050,31 +1032,24 @@ impl GrokCredentialCatalogService {
.await
.map_err(|_| GrokCredentialCatalogError::Cache)?;
}
self.publish_models(instance_config.id().as_str(), &models);
self.publish_models(&models);
Ok(FetchedInstanceCatalog {
provider_instance_id: instance_config.id().as_str().to_owned(),
Ok(FetchedProviderCatalog {
observed_at,
accounts,
models,
})
}
fn publish_models(&self, instance: &str, models: &[GrokCatalogModel]) {
fn publish_models(&self, models: &[GrokCatalogModel]) {
let mut published = self
.published
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if published
.models
.get(instance)
.is_some_and(|current| current == models)
{
if published.models == models {
return;
}
published
.models
.insert(instance.to_owned(), models.to_vec());
published.models = models.to_vec();
published.generation = published.generation.saturating_add(1);
}
}
@@ -1097,8 +1072,7 @@ struct FetchedCredentialCatalog {
seed: GrokCredentialCatalogSeed,
}
struct FetchedInstanceCatalog {
provider_instance_id: String,
struct FetchedProviderCatalog {
observed_at: DateTime<Utc>,
accounts: Vec<GrokAccountCatalog>,
models: Vec<GrokCatalogModel>,
@@ -16,7 +16,6 @@ use gateway_core::provider_ports::{
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderStoreError, provider_refresh_retry_at,
};
use gateway_core::routing::ProviderInstanceId;
use super::catalog::GrokCredentialCatalogService;
use super::repository::{
@@ -41,7 +40,6 @@ const REFRESH_RETRY_DELAY: Duration = Duration::from_secs(10 * 60);
/// 一个到期且已按 revision 读取明文 RT 的 xAI account。
pub struct DueGrokCredential {
account_id: ProviderAccountId,
provider_instance_id: ProviderInstanceId,
credential_revision: CredentialRevision,
refresh_token: SecretValue,
id_token: Option<SecretValue>,
@@ -75,7 +73,6 @@ impl fmt::Debug for DueGrokCredential {
formatter
.debug_struct("DueGrokCredential")
.field("account_id", &self.account_id)
.field("provider_instance_id", &self.provider_instance_id)
.field("credential_revision", &self.credential_revision)
.field("refresh_token", &"[REDACTED]")
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
@@ -327,7 +324,6 @@ impl GrokCredentialRefreshService {
let policy = self.runtime_policy.load_refresh_policy().await?;
let credential = DueGrokCredential {
account_id: account_id.clone(),
provider_instance_id: loaded.account.instance().clone(),
credential_revision,
refresh_token: loaded.refresh_token,
id_token: loaded.id_token,
@@ -801,7 +797,6 @@ impl GrokCredentialRepository {
};
due.push(DueGrokCredential {
account_id: account.id().clone(),
provider_instance_id: account.instance().clone(),
credential_revision: account.revision(),
refresh_token: loaded.refresh_token,
id_token: loaded.id_token,
@@ -13,7 +13,7 @@ use gateway_core::engine::credential::{
};
use gateway_core::error::StoreErrorKind;
use gateway_core::provider_ports::ProviderRefreshPolicy;
use gateway_core::routing::{ProviderInstanceId, ProviderKind};
use gateway_core::routing::ProviderKind;
use serde_json::Value;
use thiserror::Error;
@@ -36,7 +36,6 @@ const MAX_IMPORT_BATCH: usize = 200;
/// 已跨过官方 OAuth/OIDC 验证边界、等待转为 Core 创建命令的账号。
pub struct VerifiedGrokAccount {
pub account_id: ProviderAccountId,
pub provider_instance_id: ProviderInstanceId,
pub name: String,
pub email: Option<String>,
pub upstream_account_id: Option<String>,
@@ -50,7 +49,6 @@ impl std::fmt::Debug for VerifiedGrokAccount {
formatter
.debug_struct("VerifiedGrokAccount")
.field("account_id", &self.account_id)
.field("provider_instance_id", &self.provider_instance_id)
.field("name", &self.name)
.field("email", &self.email.as_ref().map(|_| "[REDACTED]"))
.field(
@@ -157,7 +155,6 @@ impl GrokCredentialAdmin {
.map_err(|_| GrokCredentialRepositoryError::InvalidInput("provider_kind"))?;
let account = ProviderAccount::new(
input.account_id.clone(),
input.provider_instance_id.clone(),
provider,
input.name.clone(),
input.account.subject.clone(),
@@ -204,7 +201,6 @@ impl GrokCredentialAdmin {
.map_err(|_| GrokCredentialRepositoryError::InvalidInput("next_refresh_at"))?;
self.prepare_import(&CreateGrokCredential {
account_id: input.account_id.clone(),
provider_instance_id: input.provider_instance_id.clone(),
name: input.name.clone(),
secret: GrokOAuthSecret {
access_token: input.tokens.access_token().clone(),
@@ -379,7 +375,6 @@ impl GrokCredentialRepository {
Ok(GrokCredentialRecord {
account_id: input.account_id.clone(),
provider_instance_id: current.account.instance().clone(),
credential_revision: revision,
})
}
@@ -476,14 +471,15 @@ impl GrokCredentialRepository {
Ok(loaded)
}
/// 读取 instance 下的全部 xAI account;credential 逐行按 revision fence 加载。
pub(crate) async fn list_loaded_for_instance(
/// 读取 xAI Provider 的全部账号;credential 逐行按 revision fence 加载。
pub(crate) async fn list_loaded_for_provider(
&self,
instance: &ProviderInstanceId,
) -> Result<Vec<LoadedGrokCredential>, GrokCredentialRepositoryError> {
let provider = ProviderKind::new(XAI_PROVIDER_KIND)
.map_err(|_| GrokCredentialRepositoryError::InvalidCredentialData)?;
let accounts = self
.store
.list_for_instance(instance)
.list_for_provider(&provider)
.await
.map_err(map_store_error)?;
let mut loaded = Vec::with_capacity(accounts.len());
@@ -495,13 +491,14 @@ impl GrokCredentialRepository {
Ok(loaded)
}
pub(crate) async fn list_accounts_for_instance(
pub(crate) async fn list_accounts_for_provider(
&self,
instance: &ProviderInstanceId,
) -> Result<Vec<ProviderAccount>, GrokCredentialRepositoryError> {
let provider = ProviderKind::new(XAI_PROVIDER_KIND)
.map_err(|_| GrokCredentialRepositoryError::InvalidCredentialData)?;
let accounts = self
.store
.list_for_instance(instance)
.list_for_provider(&provider)
.await
.map_err(map_store_error)?;
for account in &accounts {

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