fix: resolve maintainability audit findings

Serialize snapshot publication and preserve cancellation-safe execution cleanup.
Move the read-only catalog contract to routing and reuse xAI egress and JWKS state.
Unify admin post-commit observations, retain account UI state across remounts, and remove unused contracts and brittle tests.
Document regression evidence, integration verification, and remaining coverage gaps.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-12 15:17:22 +08:00
co-authored by Codex
parent 881e78975e
commit bfca754714
57 changed files with 2977 additions and 1364 deletions
+3 -22
View File
@@ -1,6 +1,9 @@
mod architecture;
mod bootstrap;
// 组合根是否混入业务策略由代码审查判断,行数和标识符/注释关键词不能证明职责越界。
// 依赖 DAG、公开模块面和模块镜像继续由 architecture 检查,源码纪律由下方测试维护。
use std::{
collections::BTreeSet,
fs,
@@ -75,28 +78,6 @@ fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
}
}
#[test]
fn bootstrap_owns_only_bundle_wiring() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let bootstrap = fs::read_to_string(root.join("bootstrap.rs")).expect("read bootstrap");
for forbidden in [
"sqlx::",
"redis::",
"Repository",
"impl Provider",
"impl ExecutionService",
"tokio::spawn",
"access_token",
"refresh_token",
] {
assert!(
!bootstrap.contains(forbidden),
"bootstrap owns `{forbidden}`"
);
}
assert!(bootstrap.lines().count() <= 300);
}
fn rust_files(root: &Path) -> BTreeSet<PathBuf> {
let mut result = BTreeSet::new();
collect_rust_files(root, root, &mut result);
@@ -15,7 +15,7 @@ use serde_json::{Map, Value};
use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
AdminError, MutationActor, MutationContext, Revision,
accounts::{
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
},
@@ -49,56 +49,6 @@ impl fmt::Debug for ProviderDocument {
}
}
/// Credential 列表稳定游标。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialCursor {
pub created_at: DateTime<Utc>,
pub account_id: ProviderAccountId,
}
/// Provider credential 列表查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialListQuery {
pub credential_state: Option<CredentialStateFilter>,
pub enabled: Option<bool>,
pub window: CredentialListWindow,
}
/// Credential 状态筛选。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialStateFilter {
Exact(CredentialState),
AnyOf(Vec<CredentialState>),
}
impl CredentialStateFilter {
#[must_use]
pub fn matches(&self, credential_state: CredentialState) -> bool {
match self {
Self::Exact(expected) => *expected == credential_state,
Self::AnyOf(expected) => expected.contains(&credential_state),
}
}
}
/// Credential 目录的集合窗口;完整列表与游标分页互斥。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialListWindow {
All,
Page {
cursor: Option<CredentialCursor>,
page_size: PageSize,
},
}
/// Provider credential 列表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialPage {
pub config_revision: Revision,
pub items: Vec<AccountRecord>,
pub next_cursor: Option<CredentialCursor>,
}
/// Provider credential 详情。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDetails {
@@ -32,8 +32,7 @@ use crate::model::{
},
provider_credentials::{
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
CredentialListQuery, CredentialMutationResult, CredentialPage, CredentialRotationCommit,
ProviderExportCredentialInput,
CredentialMutationResult, CredentialRotationCommit, ProviderExportCredentialInput,
},
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
};
@@ -110,12 +109,6 @@ pub trait AccountStore: Send + Sync {
windows: &[AccountUsageWindowQuery],
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
async fn list_credentials(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
query: CredentialListQuery,
) -> AdminStoreResult<CredentialPage>;
async fn credential_details(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
@@ -13,6 +13,8 @@ pub mod settings;
pub mod system;
pub mod xai;
use std::sync::Arc;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext,
@@ -22,7 +24,7 @@ use crate::{
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
PendingAuthorizationMutation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
StartAuthorization,
ProviderQuotaRequest, StartAuthorization,
},
},
ports::{
@@ -97,6 +99,43 @@ async fn publish_committed(
Ok(())
}
async fn publish_credentials_and_observe_quota(
provider: &Arc<dyn ProviderAdmin>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
provider.account_facts_changed(account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。
let provider = Arc::clone(provider);
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"initial quota observation failed"
);
}
}
});
Ok(())
}
async fn required_credential(
accounts: &dyn AccountStore,
provider_kind: &ProviderKind,
@@ -3,17 +3,16 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::{account::ProviderAccountId, runtime::SnapshotControl};
use gateway_core::runtime::SnapshotControl;
use crate::{
model::{
AdminError,
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
CredentialImportResult, CredentialListQuery, CredentialMutationResult, CredentialPage,
ImportCredentials, PrepareCredentialImport, PrepareCredentialRotation,
ProviderQuotaRequest, RotateCredential, StartAuthorization,
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
PrepareCredentialRotation, RotateCredential, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
@@ -21,18 +20,14 @@ use crate::{
use super::{
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
map_store_error, pending_authorization, publish_committed, required_credential,
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
map_store_error, pending_authorization, publish_committed,
publish_credentials_and_observe_quota, required_credential, validate_authorization_commit,
validate_prepared_import, validate_prepared_rotation,
};
/// OpenAI 固定管理路由消费的服务。
#[async_trait]
pub trait OpenAiService: Send + Sync {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
async fn details(
&self,
account_id: &ProviderAccountId,
) -> Result<CredentialDetails, AdminError>;
async fn import_document(
&self,
command: ImportCredentials,
@@ -77,55 +72,10 @@ impl DefaultOpenAiService {
snapshot,
}
}
fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
let provider = Arc::clone(&self.provider);
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
{
tracing::warn!(
request_id,
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"OpenAI initial quota observation failed"
);
}
}
});
}
}
#[async_trait]
impl OpenAiService for DefaultOpenAiService {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
self.accounts
.list_credentials(self.provider.provider_kind(), query)
.await
.map_err(|error| map_store_error(error, "OpenAI credential"))
}
async fn details(
&self,
account_id: &ProviderAccountId,
) -> Result<CredentialDetails, AdminError> {
required_credential(
self.accounts.as_ref(),
self.provider.provider_kind(),
account_id,
"OpenAI credential",
)
.await
}
async fn import_document(
&self,
command: ImportCredentials,
@@ -167,12 +117,14 @@ impl OpenAiService for DefaultOpenAiService {
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
drop(proxy_reservation);
self.provider
.account_facts_changed(&result.credential_ids)
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入完成后立即做一次观察;失败不回滚已经提交的 credential。
self.observe_initial_quotas(&result.credential_ids, &context.request_id);
publish_credentials_and_observe_quota(
&self.provider,
self.snapshot.as_ref(),
result.config_revision,
&result.credential_ids,
&context.request_id,
)
.await?;
Ok(result)
}
@@ -220,14 +172,14 @@ impl OpenAiService for DefaultOpenAiService {
"OpenAI authorization",
)
.await?;
self.provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
self.observe_initial_quotas(
publish_credentials_and_observe_quota(
&self.provider,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
&context.request_id,
);
)
.await?;
Ok(result)
}
@@ -11,8 +11,8 @@ use crate::{
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
CredentialListQuery, CredentialMutationResult, CredentialPage, ImportCredentials,
PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
@@ -20,14 +20,13 @@ use crate::{
use super::{
commit_authorization, delete_credentials, map_provider_error, map_store_error,
pending_authorization, publish_committed, validate_authorization_commit,
validate_prepared_import,
pending_authorization, publish_committed, publish_credentials_and_observe_quota,
validate_authorization_commit, validate_prepared_import,
};
/// xAI 固定管理路由消费的服务。
#[async_trait]
pub trait XaiService: Send + Sync {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
async fn import_document(
&self,
command: ImportCredentials,
@@ -72,13 +71,6 @@ impl DefaultXaiService {
#[async_trait]
impl XaiService for DefaultXaiService {
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
self.accounts
.list_credentials(self.provider.provider_kind(), query)
.await
.map_err(|error| map_store_error(error, "xAI credential"))
}
async fn import_document(
&self,
command: ImportCredentials,
@@ -120,18 +112,14 @@ impl XaiService for DefaultXaiService {
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
drop(proxy_reservation);
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
for account_id in &result.credential_ids {
let _ = self
.provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await;
}
publish_credentials_and_observe_quota(
&self.provider,
self.snapshot.as_ref(),
result.config_revision,
&result.credential_ids,
&context.request_id,
)
.await?;
Ok(result)
}
@@ -179,7 +167,14 @@ impl XaiService for DefaultXaiService {
"xAI authorization",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
publish_credentials_and_observe_quota(
&self.provider,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
&context.request_id,
)
.await?;
Ok(result)
}
@@ -33,10 +33,9 @@ use gateway_admin::{
AuthorizationCommit, AuthorizationCommitGuard, AuthorizationCredentialCommit,
AuthorizationMutationTarget, AuthorizationStarted, CompleteAuthorization,
ConsumeProviderResetCredit, CredentialCommitGuard, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit,
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
@@ -71,6 +70,8 @@ pub(super) struct FakeProviderAdmin {
export_inputs: Mutex<Vec<ProviderExportCredentialInput>>,
import_account_ids: Mutex<Vec<String>>,
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
quota_started: tokio::sync::Notify,
quota_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
quota: Mutex<ProviderQuota>,
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
@@ -89,6 +90,8 @@ impl FakeProviderAdmin {
export_inputs: Mutex::new(Vec::new()),
import_account_ids: Mutex::new(vec!["acct_prepared".to_owned()]),
quota_requests: Mutex::new(Vec::new()),
quota_started: tokio::sync::Notify::new(),
quota_gate: Mutex::new(None),
quota: Mutex::new(empty_quota()),
quota_refresh_account: Mutex::new(None),
current_credential_revision: Mutex::new(revision(1)),
@@ -138,6 +141,26 @@ impl FakeProviderAdmin {
.clone()
}
pub(super) fn pause_next_quota(&self) -> tokio::sync::oneshot::Sender<()> {
let (sender, receiver) = tokio::sync::oneshot::channel();
*self.quota_gate.lock().expect("quota gate") = Some(receiver);
sender
}
pub(super) async fn wait_for_quota_requests(&self, expected: usize) {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let notified = self.quota_started.notified();
if self.quota_requests().len() >= expected {
return;
}
notified.await;
}
})
.await
.expect("quota observation started");
}
fn reset_credit_commands(&self) -> Vec<ConsumeProviderResetCredit> {
self.reset_credit_commands
.lock()
@@ -377,6 +400,11 @@ impl ProviderAdmin for FakeProviderAdmin {
.lock()
.expect("provider quota requests")
.push(request);
self.quota_started.notify_one();
let gate = self.quota_gate.lock().expect("quota gate").take();
if let Some(gate) = gate {
gate.await.expect("release paused quota");
}
if let Some(kind) = self
.quota_failure
.lock()
@@ -612,23 +640,6 @@ impl AccountStore for FakeAccountStore {
.clone())
}
async fn list_credentials(
&self,
provider_kind: &ProviderKind,
_: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
self.record("store.list_credentials");
let accounts = self.accounts.lock().expect("accounts").clone();
Ok(CredentialPage {
config_revision: revision(1),
items: accounts
.into_iter()
.filter(|account| &account.provider_kind == provider_kind)
.collect(),
next_cursor: None,
})
}
async fn credential_details(
&self,
provider_kind: &ProviderKind,
@@ -45,12 +45,11 @@ use gateway_admin::{
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
ProviderModels, ProviderQuota,
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedCredentialImport, PreparedCredentialRotation, ProviderExport,
ProviderExportCredentialInput, ProviderModels, ProviderQuota,
},
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
@@ -372,14 +371,6 @@ impl AccountStore for UnavailableStore {
Err(unavailable("account quota window usage"))
}
async fn list_credentials(
&self,
_: &ProviderKind,
_: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
Err(unavailable("credentials"))
}
async fn credential_details(
&self,
_: &ProviderKind,
@@ -58,7 +58,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
.await
.expect("import credential");
await_quota_requests(&provider, 1).await;
provider.wait_for_quota_requests(1).await;
assert_eq!(
recorded(&events),
@@ -142,7 +142,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
.await
.expect("import credentials");
await_quota_requests(&provider, 2).await;
provider.wait_for_quota_requests(2).await;
assert_eq!(
result.credential_ids,
@@ -187,7 +187,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
.await
.expect("committed import remains successful");
await_quota_requests(&provider, 1).await;
provider.wait_for_quota_requests(1).await;
assert_eq!(
result.credential_ids,
@@ -224,7 +224,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
.await
.expect("complete authorization");
await_quota_requests(&provider, 1).await;
provider.wait_for_quota_requests(1).await;
assert_eq!(
recorded(&events),
@@ -280,7 +280,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
.await
.expect("quota observation must not fail authorization");
await_quota_requests(&provider, 1).await;
provider.wait_for_quota_requests(1).await;
assert_eq!(result.account_id.as_str(), "acct_prepared");
assert_eq!(provider.quota_requests().len(), 1);
@@ -322,7 +322,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
.await
.expect("released claim must permit retry");
await_quota_requests(&provider, 1).await;
provider.wait_for_quota_requests(1).await;
assert_eq!(
recorded(&events),
@@ -406,7 +406,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
.await
.expect("complete reauthorization");
await_quota_requests(&provider, 1).await;
provider.wait_for_quota_requests(1).await;
assert_eq!(result.credential_revision, Some(revision(3)));
@@ -434,16 +434,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
.await
}
async fn await_quota_requests(provider: &FakeProviderAdmin, expected: usize) {
for _ in 0..100 {
if provider.quota_requests().len() == expected {
return;
}
tokio::task::yield_now().await;
}
assert_eq!(provider.quota_requests().len(), expected);
}
fn deletion(account_id: &str) -> CredentialDeletion {
CredentialDeletion {
context: context("request-openai"),
@@ -4,7 +4,9 @@ use gateway_core::account::ProviderAccountId;
use gateway_admin::{
AdminServices,
model::provider_credentials::{ImportCredentials, ProviderQuotaRequest},
model::provider_credentials::{
CompleteAuthorization, ImportCredentials, ProviderQuotaRequest, StartAuthorization,
},
ports::provider::ProviderAdminErrorKind,
};
@@ -28,11 +30,13 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
.await
.expect("import xAI credential");
provider.wait_for_quota_requests(1).await;
assert_eq!(
recorded(&events),
[
"provider.prepare_import",
"store.commit_import",
"provider.account_facts_changed",
"provider.quota"
]
);
@@ -70,6 +74,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
.await
.expect("import credentials");
provider.wait_for_quota_requests(2).await;
assert_eq!(
provider.quota_requests(),
[
@@ -106,10 +111,120 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
.await
.expect("committed import remains successful");
provider.wait_for_quota_requests(1).await;
assert_eq!(result.credential_ids.len(), 1);
assert_eq!(provider.quota_requests().len(), 1);
}
#[tokio::test]
async fn xai_authorization_should_observe_quota_without_affecting_commit() {
for quota_failure in [None, Some(ProviderAdminErrorKind::Unavailable)] {
let events = events();
let provider = FakeProviderAdmin::new("xai", events.clone());
if let Some(kind) = quota_failure {
provider.fail_next_quota(kind);
}
let store = FakeAccountStore::new("xai", events.clone());
let services = service(provider.clone(), store.clone()).await;
services
.xai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-xai-create"),
name: "new xAI credential".to_owned(),
reauthorization: None,
})
.await
.expect("start authorization");
let result = services
.xai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-xai-create"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.await
.expect("quota observation must not fail authorization");
provider.wait_for_quota_requests(1).await;
assert_eq!(
recorded(&events),
[
"provider.start_authorization",
"provider.complete_authorization",
"store.commit_authorization",
"provider.account_facts_changed",
"provider.quota",
]
);
assert_eq!(
provider.quota_requests(),
[ProviderQuotaRequest {
account_id: result.account_id,
refresh: true,
rolling_usage: None,
}]
);
assert_eq!(store.audit_requests(), ["oauth-xai-create"]);
}
}
#[tokio::test]
async fn credential_import_should_return_while_initial_quota_is_pending() {
for kind in ["openai", "xai"] {
let events = events();
let provider = FakeProviderAdmin::new(kind, events.clone());
let quota_release = provider.pause_next_quota();
let store = FakeAccountStore::new(kind, events);
let services = service(provider.clone(), store).await;
let command = ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-slow-quota"),
document: document(),
};
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
match kind {
"openai" => services.openai().import_document(command).await,
_ => services.xai().import_document(command).await,
}
})
.await
.expect("committed import must not wait for quota")
.expect("import credentials");
provider.wait_for_quota_requests(1).await;
assert_eq!(result.credential_ids.len(), 1);
quota_release.send(()).expect("finish quota observation");
}
}
#[tokio::test]
async fn xai_import_store_failure_should_not_observe_quota_or_invalidate_provider_facts() {
let events = events();
let provider = FakeProviderAdmin::new("xai", events.clone());
let store = FakeAccountStore::new("xai", events.clone());
store.fail_next_commit();
let services = service(provider.clone(), store).await;
services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-xai-store-failure"),
document: document(),
})
.await
.expect_err("store commit fails");
assert_eq!(
recorded(&events),
["provider.prepare_import", "store.commit_import"]
);
assert!(provider.quota_requests().is_empty());
}
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
super::AdminHarness::new()
.provider(provider)
+5 -14
View File
@@ -40,12 +40,11 @@ use gateway_admin::{
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
ProviderModels, ProviderQuota,
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedCredentialImport, PreparedCredentialRotation, ProviderExport,
ProviderExportCredentialInput, ProviderModels, ProviderQuota,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ModelMappings, ReplaceRuntimeSettings,
@@ -738,14 +737,6 @@ impl AccountStore for UnusedStore {
Err(unavailable("account quota window usage"))
}
async fn list_credentials(
&self,
_: &ProviderKind,
_: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
Err(unavailable("credential list"))
}
async fn credential_details(
&self,
_: &ProviderKind,
@@ -7,6 +7,7 @@ use std::sync::{
};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use axum::{
body::Body,
http::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION},
@@ -17,18 +18,40 @@ use gateway_api::openai::responses::{
DecodedResponsesRequest, OpenAiRequestHeaders, ResponseCreateFrameError,
decode_response_create_with_context,
};
use gateway_core::account::ProviderAccountId;
use gateway_core::engine::admission::{
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
ClientAdmissionRequest, ClientAdmissionRestoreResult,
};
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
AuthenticatedClient, ClientAuthenticationError, DefaultExecutionService, ExecutionService,
ExecutionSession, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
use gateway_core::engine::provider::{
Provider, ProviderCallMetadata, ProviderRegistry, ProviderRequest, ProviderStream,
};
use gateway_core::engine::{
AttemptContext, AttemptRecord, CommitRequirement, CoordinatedEvent, EngineError,
ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest,
RecoveryReport,
};
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind, StoreError};
use gateway_core::event::{
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta,
};
use gateway_core::lifecycle::{
CancellationToken, ConnectionDraining, ConnectionGuard, ConnectionLifecycle,
};
use gateway_core::metering::{Decimal, ProviderReportedCost};
use gateway_core::operation::Operation;
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ProviderCatalogGeneration, ProviderKind, ProviderModelCapabilities, PublicModelId,
};
use gateway_core::runtime::RuntimeSnapshotHandle;
use gateway_core::upstream::{UpstreamSendState, UpstreamTransport};
use gateway_protocol::openai::codex_responses_request_semantics;
use serde_json::{Value, json};
use tokio_tungstenite::connect_async;
@@ -37,7 +60,10 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tower::ServiceExt;
use super::decode_response_create;
use crate::openai::{api_router, authenticated_client, models::ModelsExecution};
use crate::openai::{
EmptyWorkerHealth, IgnoredClientApiKeyUsage, UnusedContinuation, api_router,
authenticated_client, models::ModelsExecution, snapshot,
};
fn decode_response_create_with_turn_header(
payload: Value,
@@ -811,6 +837,352 @@ async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit(
const TEST_WEBSOCKET_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
#[derive(Default)]
struct SettlementPorts {
request_id: Mutex<Option<ModelRequestId>>,
finalizations: Mutex<Vec<ModelRequestFinalization>>,
active: AtomicBool,
settlement_calls: AtomicUsize,
settlement_started: tokio::sync::Notify,
settlement_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
charges: Mutex<Vec<ClientBudgetCharge>>,
releases: AtomicUsize,
released: tokio::sync::Notify,
}
#[async_trait]
impl ExecutionStore for SettlementPorts {
async fn create_model_request(&self, request: NewModelRequest) -> Result<(), StoreError> {
*self.request_id.lock().unwrap() = Some(request.id);
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,
finalization: ModelRequestFinalization,
) -> Result<(), StoreError> {
self.finalizations.lock().unwrap().push(finalization);
Ok(())
}
async fn recover_expired(&self, _: SystemTime) -> Result<RecoveryReport, StoreError> {
unreachable!("the WebSocket test does not recover requests")
}
}
impl ClientAdmissionPort for SettlementPorts {
fn admit(
&self,
_: ClientAdmissionRequest,
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
Box::pin(async {
assert!(!self.active.swap(true, Ordering::SeqCst));
Ok(ClientAdmissionDecision::Granted)
})
}
fn release<'a>(
&'a self,
_: &'a ClientApiKeyId,
request_id: &'a ModelRequestId,
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
Box::pin(async move {
assert_eq!(
self.charges.lock().unwrap().len(),
1,
"settlement must finish first"
);
assert_eq!(self.request_id.lock().unwrap().as_ref(), Some(request_id));
self.releases.fetch_add(1, Ordering::SeqCst);
let active = self.active.swap(false, Ordering::SeqCst);
self.released.notify_one();
Ok(active)
})
}
fn restore(
&self,
_: ClientAdmissionRecovery,
) -> BoxFuture<'_, Result<ClientAdmissionRestoreResult, ClientAdmissionError>> {
Box::pin(async { unreachable!("the WebSocket test does not restore admission") })
}
}
impl ClientBudgetPort for SettlementPorts {
fn admit(&self, _: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>> {
Box::pin(async { Ok(()) })
}
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>> {
Box::pin(async move {
self.settlement_calls.fetch_add(1, Ordering::SeqCst);
assert!(self.active.load(Ordering::SeqCst));
let gate = self
.settlement_gate
.lock()
.unwrap()
.take()
.expect("one settlement");
self.settlement_started.notify_one();
gate.await
.expect("release settlement after client disconnect");
assert!(self.active.load(Ordering::SeqCst));
self.charges.lock().unwrap().push(charge);
Ok(())
})
}
}
impl ProviderCircuitPort for SettlementPorts {
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 ChargedWebSocketProvider;
fn websocket_charge() -> Decimal {
"1.25".parse().unwrap()
}
#[async_trait]
impl Provider for ChargedWebSocketProvider {
fn name(&self) -> &'static str {
"openai"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::default()
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
async fn execute(
&self,
request: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.upstream_model().unwrap().clone(),
ProviderAccountId::new("acct_api_test").unwrap(),
UpstreamTransport::new("websocket").unwrap(),
);
let response = ResponseMeta::new("resp_ws_settlement", "model-a");
let events = [
ProviderEvent::canonical_with_wire(
vec![GatewayEvent::Started(response.clone())],
ProtocolWireEvent::json("openai", Some("response.created".to_owned()), json!({
"type": "response.created",
"response": {"id": "resp_ws_settlement", "model": "model-a", "status": "in_progress"}
})).unwrap(),
),
ProviderEvent::canonical_with_wire(
vec![
GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(websocket_charge().scaled()).unwrap(),
),
GatewayEvent::Completed(response),
],
ProtocolWireEvent::json("openai", Some("response.completed".to_owned()), json!({
"type": "response.completed",
"response": {"id": "resp_ws_settlement", "model": "model-a", "status": "completed"}
})).unwrap(),
),
];
Ok(ProviderStream::new(
metadata,
futures::stream::iter(events.map(Ok)),
(),
))
}
}
#[derive(Default)]
struct SettlementConnectionLifecycle {
closed: Arc<tokio::sync::Notify>,
}
struct SettlementConnectionGuard(Arc<tokio::sync::Notify>);
impl ConnectionGuard for SettlementConnectionGuard {}
impl Drop for SettlementConnectionGuard {
fn drop(&mut self) {
self.0.notify_one();
}
}
impl ConnectionLifecycle for SettlementConnectionLifecycle {
fn try_register(&self) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
Ok(Box::new(SettlementConnectionGuard(self.closed.clone())))
}
fn cancellation(&self) -> CancellationToken {
CancellationToken::new()
}
fn is_draining(&self) -> bool {
false
}
}
#[tokio::test]
async fn websocket_disconnect_during_core_settlement_finishes_charge_before_releasing_admission() {
let (release_settlement, gate) = tokio::sync::oneshot::channel();
let ports = Arc::new(SettlementPorts {
settlement_gate: Mutex::new(Some(gate)),
..Default::default()
});
let execution = Arc::new(
DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot("sk_ws_settlement", "openai")),
ports.clone(),
ProviderRegistry::new([Arc::new(ChargedWebSocketProvider) as Arc<dyn Provider>])
.unwrap(),
ports.clone(),
ports.clone(),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
)
.with_budget(ports.clone()),
);
let lifecycle = Arc::new(SettlementConnectionLifecycle::default());
let admin = crate::admin::AdminTestFixture::new().await;
let app = gateway_api::initialize(
gateway_api::ApiConfig {
asset_directory: std::env::temp_dir(),
cors_allowed_origins: Vec::new(),
request_timeout_seconds: None,
request_id_header: "x-request-id".to_owned(),
},
execution,
admin.services,
Vec::new(),
Arc::new(EmptyWorkerHealth),
lifecycle.clone(),
)
.unwrap()
.router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mut request = format!("ws://{address}/v1/responses")
.into_client_request()
.unwrap();
request.headers_mut().insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer sk_ws_settlement"),
);
let (mut socket, response) = connect_async(request).await.unwrap();
assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS);
socket
.send(ClientMessage::Text(
json!({
"type": "response.create", "model": "model-a", "input": "hello"
})
.to_string()
.into(),
))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(5), ports.settlement_started.notified())
.await
.expect("production Core reached settlement");
{
let finalizations = ports.finalizations.lock().unwrap();
assert_eq!(finalizations.len(), 1);
assert_eq!(
finalizations[0].outcome,
gateway_core::engine::ExecutionOutcome::Succeeded,
"unexpected execution error: {:?}",
finalizations[0].error,
);
}
assert!(ports.charges.lock().unwrap().is_empty());
assert_eq!(ports.releases.load(Ordering::SeqCst), 0);
// 等到生产连接 guard 释放,保证 forward 的 select 已因断连取消 next_event;
// 在此之前不能打开结算屏障,否则只会验证正常完成而错过取消窗口。
socket.close(None).await.unwrap();
tokio::time::timeout(Duration::from_secs(5), lifecycle.closed.notified())
.await
.expect("production WebSocket handler exited");
assert!(ports.active.load(Ordering::SeqCst));
release_settlement
.send(())
.expect("the original settlement future survived disconnect");
tokio::time::timeout(Duration::from_secs(5), ports.released.notified())
.await
.expect("detached Core cleanup released admission");
assert!(!ports.active.load(Ordering::SeqCst));
assert_eq!(ports.releases.load(Ordering::SeqCst), 1);
assert_eq!(ports.settlement_calls.load(Ordering::SeqCst), 1);
{
let charges = ports.charges.lock().unwrap();
assert_eq!(charges.len(), 1);
assert_eq!(charges[0].amount_usd, websocket_charge());
assert_eq!(charges[0].key_id.as_str(), "key_api_test");
assert_eq!(
Some(&charges[0].request_id),
ports.request_id.lock().unwrap().as_ref()
);
}
server.abort();
let _ = server.await;
}
fn upgrade_request(authorization: &str) -> Request<Body> {
Request::get("/v1/responses")
.header(AUTHORIZATION, authorization)
@@ -173,8 +173,11 @@ pub trait ExecutionSession: Send {
&mut self,
client_status_code: u16,
) -> BoxFuture<'_, Result<(), EngineError>>;
/// 执行已终结且结算、准入释放均已返回,协议层才可以放弃清理责任。
/// 结算失败仍由 Store 保留费用重试,释放失败仍按租约 TTL 收敛。
fn is_finalized(&self) -> bool;
fn cancel(&self);
/// 将会话交给宿主持续驱动清理;取消请求事件的等待不会丢弃已开始的结算。
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()>;
}
@@ -978,8 +981,9 @@ impl AdmissionLease {
}
struct DefaultExecutionSession {
core: Option<ResponseExecutionSession<dyn ExecutionStore>>,
core: ResponseExecutionSession<dyn ExecutionStore>,
admission: Option<AdmissionLease>,
cleanup: Option<BoxFuture<'static, ()>>,
circuits: Arc<dyn ProviderCircuitPort>,
continuation: Arc<dyn NativeContinuationPort>,
observed_provider_outcomes: usize,
@@ -996,8 +1000,9 @@ impl DefaultExecutionSession {
budget: Option<Arc<dyn ClientBudgetPort>>,
) -> Self {
Self {
core: Some(core),
core,
admission: Some(admission),
cleanup: None,
circuits,
continuation,
observed_provider_outcomes: 0,
@@ -1006,33 +1011,29 @@ impl DefaultExecutionSession {
}
}
fn core_mut(
&mut self,
) -> Result<&mut ResponseExecutionSession<dyn ExecutionStore>, EngineError> {
self.core.as_mut().ok_or(EngineError::InvalidDeliveryState)
}
async fn settle_if_finalized(&mut self) {
if self
.core
.as_ref()
.is_some_and(ResponseExecutionSession::is_finalized)
if self.core.is_finalized()
&& let Some(admission) = self.admission.take()
{
if let Some(budget) = self.budget.take()
&& let Some(core) = &self.core
{
settle_budget(budget.as_ref(), core.budget_charge()).await;
}
admission.release().await;
let budget = self.budget.take();
let charge = self.core.budget_charge();
// 在首次 await 前把完整清理责任留在会话内。事件等待被取消后,后续 poll
// 或 detach 继续同一个 future,既不丢失费用,也不重启已完成的结算。
self.cleanup = Some(Box::pin(async move {
if let Some(budget) = budget {
settle_budget(budget.as_ref(), charge).await;
}
admission.release().await;
}));
}
if let Some(cleanup) = self.cleanup.as_mut() {
cleanup.await;
self.cleanup = None;
}
}
async fn observe_provider_outcomes(&mut self) {
let Some(core) = self.core.as_ref() else {
return;
};
let outcomes = core.provider_attempt_outcomes();
let outcomes = self.core.provider_attempt_outcomes();
let new_outcomes = outcomes
.get(self.observed_provider_outcomes..)
.unwrap_or_default()
@@ -1048,11 +1049,7 @@ impl DefaultExecutionSession {
let Some(state) = state else {
return;
};
let Some(pin) = self
.core
.as_ref()
.and_then(|core| core.native_continuation_pin(state))
else {
let Some(pin) = self.core.native_continuation_pin(state) else {
return;
};
self.continuation_recorded = true;
@@ -1060,47 +1057,27 @@ impl DefaultExecutionSession {
}
async fn finalize_detached(&mut self) {
let Some(mut core) = self.core.take() else {
return;
};
core.cancel();
if !core.is_finalized()
&& let Err(error) = core.cancel_and_finalize().await
{
if let Err(error) = self.core.cancel_and_finalize().await {
tracing::warn!(%error, "Detached execution 终态收敛失败");
}
let pending = core
.provider_attempt_outcomes()
.get(self.observed_provider_outcomes..)
.unwrap_or_default();
publish_provider_attempt_outcomes(self.circuits.as_ref(), pending).await;
if let Some(budget) = self.budget.take() {
settle_budget(budget.as_ref(), core.budget_charge()).await;
}
if let Some(admission) = self.admission.take() {
admission.release().await;
}
self.observe_provider_outcomes().await;
self.settle_if_finalized().await;
}
}
impl Drop for DefaultExecutionSession {
fn drop(&mut self) {
if let Some(core) = &self.core {
core.cancel();
}
self.core.cancel();
}
}
impl ExecutionSession for DefaultExecutionSession {
fn trace(&self) -> crate::diagnostics::TraceContext {
self.core
.as_ref()
.map(ResponseExecutionSession::trace)
.unwrap_or_default()
self.core.trace()
}
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async move {
let result = self.core_mut()?.next_event().await;
let result = self.core.next_event().await;
if let Ok(Some(event)) = result.as_ref() {
self.record_continuation(event.session_update()).await;
}
@@ -1112,7 +1089,7 @@ impl ExecutionSession for DefaultExecutionSession {
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
Box::pin(async move {
let result = self.core_mut()?.collect_uncommitted().await;
let result = self.core.collect_uncommitted().await;
if let Ok(events) = result.as_ref() {
let state = events.iter().find_map(ProviderEvent::session_update);
self.record_continuation(state).await;
@@ -1124,16 +1101,11 @@ impl ExecutionSession for DefaultExecutionSession {
}
fn response_headers(&self) -> &[ProviderResponseHeader] {
self.core
.as_ref()
.map(ResponseExecutionSession::response_headers)
.unwrap_or_default()
self.core.response_headers()
}
fn response_status_code(&self) -> Option<u16> {
self.core
.as_ref()
.and_then(ResponseExecutionSession::response_status_code)
self.core.response_status_code()
}
fn commit_downstream(
@@ -1141,7 +1113,7 @@ impl ExecutionSession for DefaultExecutionSession {
client_status_code: Option<u16>,
) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async move {
let result = self.core_mut()?.commit_downstream(client_status_code).await;
let result = self.core.commit_downstream(client_status_code).await;
self.observe_provider_outcomes().await;
self.settle_if_finalized().await;
result
@@ -1153,10 +1125,7 @@ impl ExecutionSession for DefaultExecutionSession {
client_status_code: u16,
) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async move {
let result = self
.core_mut()?
.record_client_status(client_status_code)
.await;
let result = self.core.record_client_status(client_status_code).await;
self.observe_provider_outcomes().await;
self.settle_if_finalized().await;
result
@@ -1164,15 +1133,11 @@ impl ExecutionSession for DefaultExecutionSession {
}
fn is_finalized(&self) -> bool {
self.core
.as_ref()
.is_none_or(ResponseExecutionSession::is_finalized)
self.core.is_finalized() && self.admission.is_none() && self.cleanup.is_none()
}
fn cancel(&self) {
if let Some(core) = &self.core {
core.cancel();
}
self.core.cancel();
}
fn detach_finalize(mut self: Box<Self>) -> BoxFuture<'static, ()> {
@@ -8,7 +8,7 @@ use std::task::{Context, Poll};
use std::time::Instant;
use async_trait::async_trait;
use futures::Stream;
use futures::{Stream, future::BoxFuture};
use thiserror::Error;
use crate::account::{
@@ -21,7 +21,8 @@ use crate::identity::ProviderKind;
use crate::operation::Operation;
use crate::policy::ClientApiKeyId;
use crate::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, PublicModelId, UpstreamModelId,
ProviderCandidate, ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities, PublicModelId, UpstreamModelId,
};
use crate::upstream::OpaqueUpstreamValue;
use crate::upstream::{UpstreamSendState, UpstreamTransport};
@@ -437,64 +438,6 @@ pub struct ContinuationRequestObservation {
pub requested: bool,
}
/// Provider 实时目录编译后的单模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelCapabilities {
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
presentation: Option<ModelPresentation>,
}
/// Provider 实时目录成功发布后的进程内单调代次。
///
/// 代次只表达“目录内容已经变化”,不承载模型、ETag 或 Provider 私有数据。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProviderCatalogGeneration(u64);
impl ProviderCatalogGeneration {
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl ProviderModelCapabilities {
#[must_use]
pub const fn new(upstream_model: UpstreamModelId, capabilities: ModelCapabilities) -> Self {
Self {
upstream_model,
capabilities,
presentation: None,
}
}
#[must_use]
pub fn with_presentation(mut self, presentation: ModelPresentation) -> Self {
self.presentation = Some(presentation);
self
}
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
}
#[must_use]
pub const fn capabilities(&self) -> &ModelCapabilities {
&self.capabilities
}
#[must_use]
pub const fn presentation(&self) -> Option<&ModelPresentation> {
self.presentation.as_ref()
}
}
impl ProviderRequest {
/// 绑定 operation 与请求计划中冻结的 Provider 候选。
#[must_use]
@@ -586,15 +529,6 @@ pub enum RegistryError {
},
}
/// Registry 实时目录查询错误。
#[derive(Debug, Error)]
pub enum ProviderCatalogError {
#[error("provider `{provider}` is not registered")]
NotRegistered { provider: String },
#[error("provider model catalog query failed")]
Query(#[source] ProviderError),
}
/// 唯一保存 `Arc<dyn Provider>` 的异构注册表。
#[derive(Default)]
pub struct ProviderRegistryBuilder {
@@ -665,11 +599,6 @@ 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,
@@ -684,35 +613,6 @@ impl ProviderRegistry {
})
}
/// 读取全部 Provider 的目录代次,用于 Core 快照稳定性校验与对账。
#[must_use]
pub fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
self.providers
.iter()
.map(|(kind, provider)| (kind.clone(), provider.catalog_generation()))
.collect()
}
/// 通过编译期 Provider adapter 查询并编译实时能力目录。
///
/// # Errors
///
/// Provider 未注册或目录查询失败时返回错误。
pub async fn query_model_capabilities(
&self,
provider_kind: &ProviderKind,
) -> Result<Vec<ProviderModelCapabilities>, ProviderCatalogError> {
let provider = self.providers.get(provider_kind).ok_or_else(|| {
ProviderCatalogError::NotRegistered {
provider: provider_kind.as_str().to_owned(),
}
})?;
provider
.query_model_capabilities()
.await
.map_err(ProviderCatalogError::Query)
}
/// 判断 Provider 是否已注册。
#[must_use]
pub fn contains(&self, provider: &ProviderKind) -> bool {
@@ -731,3 +631,26 @@ impl ProviderRegistry {
self.providers.is_empty()
}
}
impl ProviderCatalogPort for ProviderRegistry {
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
self.providers
.iter()
.map(|(kind, provider)| (kind.clone(), provider.catalog_generation()))
.collect()
}
fn query_model_capabilities(
&self,
provider_kind: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>> {
let provider = self.providers.get(provider_kind);
Box::pin(async move {
provider
.ok_or(ProviderCatalogUnavailable)?
.query_model_capabilities()
.await
.map_err(|_| ProviderCatalogUnavailable)
})
}
}
+1 -1
View File
@@ -140,7 +140,7 @@ pub async fn initialize(
.map_err(|_| CoreError::AdmissionRecoveryUnavailable)?;
let compiler = Arc::new(RuntimeSnapshotCompiler::new(
Arc::clone(&ports.snapshots),
providers.clone(),
Arc::new(providers.clone()),
));
let initial = compiler
.compile()
@@ -0,0 +1,86 @@
//! 快照编译需要的 Provider 目录事实,不暴露执行或账号选择能力。
use std::collections::BTreeMap;
use futures::future::BoxFuture;
use crate::identity::ProviderKind;
use super::{ModelCapabilities, ModelPresentation, UpstreamModelId};
/// Provider 实时目录编译后的单模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelCapabilities {
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
presentation: Option<ModelPresentation>,
}
impl ProviderModelCapabilities {
#[must_use]
pub const fn new(upstream_model: UpstreamModelId, capabilities: ModelCapabilities) -> Self {
Self {
upstream_model,
capabilities,
presentation: None,
}
}
#[must_use]
pub fn with_presentation(mut self, presentation: ModelPresentation) -> Self {
self.presentation = Some(presentation);
self
}
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
}
#[must_use]
pub const fn capabilities(&self) -> &ModelCapabilities {
&self.capabilities
}
#[must_use]
pub const fn presentation(&self) -> Option<&ModelPresentation> {
self.presentation.as_ref()
}
}
/// Provider 实时目录成功发布后的进程内单调代次。
///
/// 代次只表达“目录内容已经变化”,不承载模型、ETag 或 Provider 私有数据。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProviderCatalogGeneration(u64);
impl ProviderCatalogGeneration {
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
/// 目录未知,不能据此判定某个模型不存在;不携带执行错误或上游协议细节。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("provider model catalog is unavailable")]
pub struct ProviderCatalogUnavailable;
/// 快照编译与对账使用的对象安全目录端口。
pub trait ProviderCatalogPort: Send + Sync {
/// 返回全部已注册 Provider 的目录代次;注册集合在初始化后保持不变。
///
/// 即使某个目录暂时不可读,也必须保留它的 Provider 与最近成功发布的代次。
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration>;
/// 查询指定 Provider 的模型事实;成功的空列表表示已知无模型,失败表示未知。
fn query_model_capabilities(
&self,
provider: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>>;
}
@@ -1,5 +1,6 @@
//! Provider、模型目录、精确模型映射与请求级候选计划。
mod catalog;
pub mod snapshot;
pub use crate::account::scope::{
@@ -7,6 +8,10 @@ pub use crate::account::scope::{
FrozenAccountScope, RoutingGroupSnapshot, RuntimeAccount, RuntimeAccountDirectory,
};
pub use crate::identity::ProviderKind;
pub use catalog::{
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities,
};
pub use snapshot::RuntimeSnapshot;
use std::collections::{BTreeMap, BTreeSet};
@@ -8,7 +8,6 @@ use std::time::Duration;
use futures::future::BoxFuture;
use crate::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy};
use crate::engine::provider::{ProviderCatalogGeneration, ProviderRegistry};
use crate::operation::Operation;
use crate::policy::{
ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion,
@@ -18,8 +17,9 @@ use crate::validation::RoutingError;
use super::{
AccountGroupId, ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities,
ProviderCandidate, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
RoutingGroupSnapshot, RoutingPlan, RuntimeAccount, RuntimeAccountDirectory, UpstreamModelId,
ProviderCandidate, ProviderCatalogGeneration, ProviderCatalogPort, ProviderKind, ProviderModel,
PublicModelId, RoutingContext, RoutingGroupSnapshot, RoutingPlan, RuntimeAccount,
RuntimeAccountDirectory, UpstreamModelId,
};
const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
@@ -212,13 +212,16 @@ pub enum RuntimeSnapshotCompileError {
#[derive(Clone)]
pub struct RuntimeSnapshotCompiler {
store: Arc<dyn SnapshotStorePort>,
providers: ProviderRegistry,
catalogs: Arc<dyn ProviderCatalogPort>,
}
impl RuntimeSnapshotCompiler {
#[must_use]
pub const fn new(store: Arc<dyn SnapshotStorePort>, providers: ProviderRegistry) -> Self {
Self { store, providers }
pub const fn new(
store: Arc<dyn SnapshotStorePort>,
catalogs: Arc<dyn ProviderCatalogPort>,
) -> Self {
Self { store, catalogs }
}
pub(crate) fn store(&self) -> Arc<dyn SnapshotStorePort> {
@@ -228,13 +231,13 @@ impl RuntimeSnapshotCompiler {
pub(crate) fn provider_catalog_generations(
&self,
) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
self.providers.catalog_generations()
self.catalogs.catalog_generations()
}
/// 读取一个 revision,并为已注册 Provider 查询实时模型目录。
pub async fn compile(&self) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
for _ in 0..MAXIMUM_CATALOG_STABILITY_ATTEMPTS {
let catalog_generations = self.providers.catalog_generations();
let catalog_generations = self.catalogs.catalog_generations();
let facts = self
.store
.load_snapshot_facts()
@@ -243,8 +246,10 @@ impl RuntimeSnapshotCompiler {
if facts.config_revision != facts.observed_current_revision {
return Err(RuntimeSnapshotCompileError::RevisionChanged);
}
let snapshot = compile_runtime_snapshot(facts, &self.providers).await?;
let observed_generations = self.providers.catalog_generations();
let provider_kinds = catalog_generations.keys().cloned().collect();
let snapshot =
compile_runtime_snapshot(facts, self.catalogs.as_ref(), provider_kinds).await?;
let observed_generations = self.catalogs.catalog_generations();
if catalog_generations == observed_generations {
return Ok(snapshot.with_provider_catalog_generations(observed_generations));
}
@@ -255,16 +260,16 @@ impl RuntimeSnapshotCompiler {
async fn compile_runtime_snapshot(
facts: SnapshotFacts,
providers: &ProviderRegistry,
catalogs: &dyn ProviderCatalogPort,
provider_kinds: Vec<ProviderKind>,
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
let provider_kinds = providers.provider_kinds().cloned().collect::<Vec<_>>();
let registered_providers = provider_kinds.iter().cloned().collect::<BTreeSet<_>>();
// 目录查询失败表示未知;查询成功后,即使为空,也必须与“已知缺少模型”区分。
let mut provider_models = Vec::new();
let mut known_provider_catalogs = BTreeSet::new();
for provider in &provider_kinds {
let Ok(models) = providers.query_model_capabilities(provider).await else {
let Ok(models) = catalogs.query_model_capabilities(provider).await else {
continue;
};
known_provider_catalogs.insert(provider.clone());
+57 -45
View File
@@ -6,17 +6,17 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use futures::future::BoxFuture;
use futures::lock::Mutex;
use futures::{FutureExt as _, Stream, StreamExt as _, pin_mut, select_biased};
use futures_timer::Delay;
use crate::engine::provider::ProviderCatalogGeneration;
use crate::health::{HealthProbe, HealthState};
use crate::identity::ProviderKind;
use crate::lifecycle::CancellationToken;
use crate::routing::ConfigRevision;
use crate::routing::snapshot::{
RuntimeSnapshot, RuntimeSnapshotCompileError, RuntimeSnapshotCompiler, SnapshotStorePort,
RuntimeSnapshot, RuntimeSnapshotCompileError, RuntimeSnapshotCompiler,
};
use crate::routing::{ConfigRevision, ProviderCatalogGeneration};
use crate::task::{
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
WorkerDefinitionError, WorkerId, WorkerKind, WorkerRegistration, WorkerRunnable,
@@ -135,11 +135,17 @@ pub struct RuntimeSnapshotPublisher {
compiler: Arc<RuntimeSnapshotCompiler>,
snapshots: RuntimeSnapshotHandle,
subscriptions: Arc<dyn SnapshotSubscriptionPort>,
refresh_lock: Arc<Mutex<()>>,
}
enum RefreshMode {
Required,
Reconcile,
}
impl RuntimeSnapshotPublisher {
#[must_use]
pub const fn new(
pub fn new(
compiler: Arc<RuntimeSnapshotCompiler>,
snapshots: RuntimeSnapshotHandle,
subscriptions: Arc<dyn SnapshotSubscriptionPort>,
@@ -148,21 +154,55 @@ impl RuntimeSnapshotPublisher {
compiler,
snapshots,
subscriptions,
refresh_lock: Arc::new(Mutex::new(())),
}
}
/// 重新编译并原子替换本进程快照。
/// 串行重编译并替换本进程快照;无法确认配置时暂停新请求。
pub async fn refresh(&self) -> Result<ConfigRevision, RuntimeSnapshotCompileError> {
let snapshot = self.compiler.compile().await?;
self.refresh_with_mode(RefreshMode::Required).await
}
async fn refresh_with_mode(
&self,
mode: RefreshMode,
) -> Result<ConfigRevision, RuntimeSnapshotCompileError> {
// 所有入口共享从读取事实到发布或暂停的完整临界区;只锁最后的替换会让
// 旧编译覆盖新授权,或让晚到的失败暂停新快照。请求读取不等待此锁。
let _refresh = self.refresh_lock.lock().await;
let configuration_changed = if matches!(mode, RefreshMode::Reconcile) {
let persisted_revision = self
.compiler
.store()
.current_config_revision()
.await
.map_err(|_| {
self.snapshots.suspend();
RuntimeSnapshotCompileError::StoreUnavailable
})?;
let changed = runtime_revision_needs_refresh(
self.published_revision().map(ConfigRevision::get),
persisted_revision.get(),
);
if !changed && !self.provider_catalogs_need_refresh() {
return Ok(persisted_revision);
}
changed
} else {
true
};
let snapshot = self.compiler.compile().await.inspect_err(|_| {
// 仅目录代次变化时保留旧快照,下一周期继续对账;配置缺失、变化
// 或持久 revision 回退均须 fail closed,不按 revision 大小丢弃刷新。
if configuration_changed {
self.snapshots.suspend();
}
})?;
let revision = snapshot.revision();
self.snapshots.publish(snapshot);
Ok(revision)
}
pub fn suspend(&self) {
self.snapshots.suspend();
}
#[must_use]
pub fn published_revision(&self) -> Option<ConfigRevision> {
self.snapshots.revision()
@@ -176,9 +216,7 @@ impl RuntimeSnapshotPublisher {
/// 数据库提交不能被目录或通知基础设施的暂时故障伪装成回滚。
async fn publish_committed_inner(&self, committed_revision: ConfigRevision) {
if self.refresh().await.is_err() {
self.suspend();
}
let _ = self.refresh().await;
let _ = self
.subscriptions
.publish_snapshot_revision(committed_revision)
@@ -204,7 +242,6 @@ impl RuntimeSnapshotPublisher {
schedule,
lease: None,
task: Box::new(RuntimeSnapshotReconciliationTask {
store: self.compiler.store(),
publisher: self.clone(),
}),
},
@@ -238,7 +275,6 @@ impl SnapshotControl for RuntimeSnapshotPublisher {
}
struct RuntimeSnapshotReconciliationTask {
store: Arc<dyn SnapshotStorePort>,
publisher: RuntimeSnapshotPublisher,
}
@@ -248,33 +284,11 @@ impl ScheduledTask for RuntimeSnapshotReconciliationTask {
_context: WorkerCycleContext,
) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let persisted_revision = match self.store.current_config_revision().await {
Ok(revision) => revision,
Err(_) => {
self.publisher.suspend();
return Err(WorkerTaskError::safe(
"runtime snapshot revision is unavailable",
));
}
};
let configuration_changed = runtime_revision_needs_refresh(
self.publisher.published_revision().map(ConfigRevision::get),
persisted_revision.get(),
);
if !configuration_changed && !self.publisher.provider_catalogs_need_refresh() {
return Ok(());
}
if self.publisher.refresh().await.is_err() {
// 已提交的配置无法确认时必须 fail closed;仅目录重编译失败时继续
// 服务旧的不可变快照,单调代次会让下一周期再次尝试。
if configuration_changed {
self.publisher.suspend();
}
return Err(WorkerTaskError::safe(
"runtime snapshot reconciliation failed",
));
}
Ok(())
self.publisher
.refresh_with_mode(RefreshMode::Reconcile)
.await
.map(|_| ())
.map_err(|_| WorkerTaskError::safe("runtime snapshot reconciliation failed"))
})
}
}
@@ -314,9 +328,7 @@ impl DaemonTask for RuntimeSnapshotSubscriptionTask {
};
match notified {
Some(Ok(_)) => {
if self.publisher.refresh().await.is_err() {
self.publisher.suspend();
}
let _ = self.publisher.refresh().await;
}
Some(Err(_)) | None => break,
}
@@ -16,8 +16,7 @@ use gateway_core::engine::continuation::{
};
use gateway_core::engine::execution::gateway_error_from_engine;
use gateway_core::engine::provider::{
Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities,
ProviderRegistry, ProviderRequest, ProviderStream,
Provider, ProviderCallMetadata, ProviderRegistry, ProviderRequest, ProviderStream,
};
use gateway_core::engine::{
AttemptContext, AttemptCoordinator, AttemptRecord, AttemptTransport, CommitRequirement,
@@ -41,8 +40,9 @@ use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, Provi
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
AccountRoutingSnapshot, ClientRoutingScope, ConfigRevision, FrozenAccountScope,
ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext, RoutingPlan,
RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
ModelCapabilities, ProviderCatalogGeneration, ProviderKind, ProviderModel,
ProviderModelCapabilities, PublicModelId, RoutingContext, RoutingPlan, RuntimeAccount,
RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::upstream::{UpstreamSendState, UpstreamTransport};
use serde_json::{Map, Value, json};
@@ -2,7 +2,7 @@ use std::{
collections::{BTreeMap, BTreeSet},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::{Duration, Instant, SystemTime},
};
@@ -14,6 +14,8 @@ use gateway_core::error::GatewayError;
struct Admissions {
active: Arc<AtomicBool>,
limits: Mutex<Vec<RateLimits>>,
release_gate: Mutex<Option<oneshot::Receiver<()>>>,
releases: AtomicUsize,
}
impl ClientAdmissionPort for Admissions {
@@ -32,7 +34,14 @@ impl ClientAdmissionPort for Admissions {
_: &'a ClientApiKeyId,
_: &'a ModelRequestId,
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
Box::pin(async { Ok(self.active.swap(false, Ordering::SeqCst)) })
Box::pin(async {
self.releases.fetch_add(1, Ordering::SeqCst);
let gate = self.release_gate.lock().unwrap().take();
if let Some(gate) = gate {
gate.await.expect("release gate");
}
Ok(self.active.swap(false, Ordering::SeqCst))
})
}
fn restore(
&self,
@@ -42,10 +51,14 @@ impl ClientAdmissionPort for Admissions {
}
}
#[derive(Default)]
struct Budget {
reject: bool,
active: Arc<AtomicBool>,
charges: Mutex<Vec<ClientBudgetCharge>>,
settlement_gate: Mutex<Option<oneshot::Receiver<()>>>,
settlements: AtomicUsize,
fail_settlement: bool,
}
impl ClientBudgetPort for Budget {
@@ -64,12 +77,22 @@ impl ClientBudgetPort for Budget {
}
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>> {
Box::pin(async move {
self.settlements.fetch_add(1, Ordering::SeqCst);
assert!(
self.active.load(Ordering::SeqCst),
"settle before releasing the concurrent request slot"
);
let gate = self.settlement_gate.lock().unwrap().take();
if let Some(gate) = gate {
gate.await.expect("settlement gate");
}
assert!(self.active.load(Ordering::SeqCst));
self.charges.lock().unwrap().push(charge);
Ok(())
if self.fail_settlement {
Err(ClientBudgetError)
} else {
Ok(())
}
})
}
}
@@ -111,6 +134,7 @@ fn budget_rejection_releases_client_concurrency_without_creating_a_charge() {
reject: true,
active: admissions.active.clone(),
charges: Mutex::default(),
..Default::default()
});
let service = service(admissions.clone(), budget.clone());
for transport in [
@@ -200,6 +224,7 @@ fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all
reject: false,
active: admissions.active.clone(),
charges: Mutex::default(),
..Default::default()
});
let service = service(admissions.clone(), budget.clone());
for transport in [
@@ -232,9 +257,336 @@ fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all
);
}
struct ChargedProvider {
fail: bool,
}
fn known_charge() -> Decimal {
"1.25".parse().unwrap()
}
#[async_trait]
impl Provider for ChargedProvider {
fn name(&self) -> &'static str {
"openai"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::default()
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
async fn execute(
&self,
request: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.upstream_model().unwrap().clone(),
ProviderAccountId::new("acct_start").unwrap(),
UpstreamTransport::new("websocket").unwrap(),
);
let response = ResponseMeta::new("resp_cleanup", "gpt-start");
let terminal = if self.fail {
Err(ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::Sent,
))
} else {
Ok(GatewayEvent::Completed(response.clone()).into())
};
let events: Vec<Result<ProviderEvent, ProviderError>> = vec![
Ok(GatewayEvent::Started(response).into()),
Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(known_charge().scaled()).unwrap(),
)
.into()),
terminal,
];
Ok(ProviderStream::new(
metadata,
futures::stream::iter(events),
(),
))
}
}
fn charged_service(
admissions: Arc<Admissions>,
budget: Option<Arc<Budget>>,
fail: bool,
) -> DefaultExecutionService {
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(start_snapshot()),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([Arc::new(ChargedProvider { fail }) as Arc<dyn Provider>]).unwrap(),
admissions,
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
match budget {
Some(budget) => service.with_budget(budget),
None => service,
}
}
async fn consume_charged_prefix(session: &mut dyn ExecutionSession) {
let first = session.next_event().await.unwrap().unwrap();
assert_eq!(
first.commit_requirement(),
CommitRequirement::CommitBeforeDelivery
);
session.commit_downstream(None).await.unwrap();
let cost = session.next_event().await.unwrap().unwrap();
assert_eq!(
cost.commit_requirement(),
CommitRequirement::AlreadyCommitted
);
}
fn assert_cleanup_completed(admissions: &Admissions, budget: &Budget, request_id: &ModelRequestId) {
assert!(!admissions.active.load(Ordering::SeqCst));
assert_eq!(admissions.releases.load(Ordering::SeqCst), 1);
assert_eq!(budget.settlements.load(Ordering::SeqCst), 1);
let charges = budget.charges.lock().unwrap();
assert_eq!(charges.len(), 1);
assert_eq!(charges[0].request_id, *request_id);
assert_eq!(charges[0].key_id.as_str(), "key_start_test");
assert_eq!(charges[0].amount_usd, known_charge());
}
#[test]
fn normal_completion_settles_known_charge_once_for_all_transports() {
block_on(async {
for transport in [
ClientTransport::HttpJson,
ClientTransport::HttpSse,
ClientTransport::WebSocket,
] {
let admissions = Arc::new(Admissions::default());
let budget = Arc::new(Budget {
active: admissions.active.clone(),
..Default::default()
});
let service = charged_service(admissions.clone(), Some(budget.clone()), false);
let mut started = service.start(request(&service, transport)).await.unwrap();
if transport == ClientTransport::HttpJson {
assert_eq!(
started.session.collect_uncommitted().await.unwrap().len(),
3
);
assert!(!started.session.is_finalized());
started.session.commit_downstream(Some(200)).await.unwrap();
} else {
consume_charged_prefix(started.session.as_mut()).await;
assert!(started.session.next_event().await.unwrap().is_some());
}
assert!(started.session.next_event().await.unwrap().is_none());
assert!(started.session.is_finalized());
started.session.detach_finalize().await;
assert_cleanup_completed(&admissions, &budget, &started.request_id);
}
});
}
#[test]
fn settlement_survives_cancelled_event_wait_and_resumes_without_restart() {
block_on(async {
for fail in [false, true] {
let admissions = Arc::new(Admissions::default());
let (release, gate) = oneshot::channel();
let budget = Arc::new(Budget {
active: admissions.active.clone(),
settlement_gate: Mutex::new(Some(gate)),
..Default::default()
});
let service = charged_service(admissions.clone(), Some(budget.clone()), fail);
let mut started = service
.start(request(&service, ClientTransport::WebSocket))
.await
.unwrap();
consume_charged_prefix(started.session.as_mut()).await;
if !fail {
assert!(started.session.next_event().await.unwrap().is_some());
}
for _ in 0..2 {
let mut terminal = started.session.next_event();
assert!(futures::poll!(terminal.as_mut()).is_pending());
drop(terminal);
assert!(!started.session.is_finalized());
assert_eq!(budget.settlements.load(Ordering::SeqCst), 1);
assert!(budget.charges.lock().unwrap().is_empty());
assert_eq!(admissions.releases.load(Ordering::SeqCst), 0);
}
release
.send(())
.expect("the original settlement is still alive");
assert!(started.session.next_event().await.unwrap().is_none());
assert!(started.session.is_finalized());
started.session.detach_finalize().await;
assert_cleanup_completed(&admissions, &budget, &started.request_id);
}
});
}
#[test]
fn detached_cleanup_settles_cancelled_execution_and_resumes_existing_settlement() {
block_on(async {
for cancel_event_wait in [false, true] {
let admissions = Arc::new(Admissions::default());
let (release, gate) = oneshot::channel();
let budget = Arc::new(Budget {
active: admissions.active.clone(),
settlement_gate: Mutex::new(Some(gate)),
..Default::default()
});
let service = charged_service(admissions.clone(), Some(budget.clone()), false);
let mut started = service
.start(request(&service, ClientTransport::WebSocket))
.await
.unwrap();
consume_charged_prefix(started.session.as_mut()).await;
if cancel_event_wait {
started.session.cancel();
let mut terminal = started.session.next_event();
assert!(futures::poll!(terminal.as_mut()).is_pending());
drop(terminal);
assert!(!started.session.is_finalized());
}
let mut detached = started.session.detach_finalize();
assert!(futures::poll!(detached.as_mut()).is_pending());
assert_eq!(budget.settlements.load(Ordering::SeqCst), 1);
assert_eq!(admissions.releases.load(Ordering::SeqCst), 0);
release
.send(())
.expect("detached cleanup retains settlement");
detached.await;
assert_cleanup_completed(&admissions, &budget, &started.request_id);
}
});
}
#[test]
fn cancelled_release_resumes_without_repeating_settlement_with_or_without_budget() {
block_on(async {
for with_budget in [false, true] {
let (release, gate) = oneshot::channel();
let admissions = Arc::new(Admissions {
release_gate: Mutex::new(Some(gate)),
..Default::default()
});
let budget = Arc::new(Budget {
active: admissions.active.clone(),
..Default::default()
});
let service = charged_service(
admissions.clone(),
with_budget.then(|| budget.clone()),
false,
);
let mut started = service
.start(request(&service, ClientTransport::HttpSse))
.await
.unwrap();
consume_charged_prefix(started.session.as_mut()).await;
assert!(started.session.next_event().await.unwrap().is_some());
let mut terminal = started.session.next_event();
assert!(futures::poll!(terminal.as_mut()).is_pending());
drop(terminal);
assert!(!started.session.is_finalized());
assert!(admissions.active.load(Ordering::SeqCst));
assert_eq!(
budget.settlements.load(Ordering::SeqCst),
usize::from(with_budget)
);
let mut detached = started.session.detach_finalize();
assert!(futures::poll!(detached.as_mut()).is_pending());
assert_eq!(admissions.releases.load(Ordering::SeqCst), 1);
release
.send(())
.expect("the original release is still alive");
detached.await;
if with_budget {
assert_cleanup_completed(&admissions, &budget, &started.request_id);
} else {
assert!(!admissions.active.load(Ordering::SeqCst));
assert_eq!(admissions.releases.load(Ordering::SeqCst), 1);
assert!(budget.charges.lock().unwrap().is_empty());
}
}
});
}
#[test]
fn cancelled_buffered_commit_keeps_settlement_for_detached_cleanup() {
block_on(async {
let admissions = Arc::new(Admissions::default());
let (release, gate) = oneshot::channel();
let budget = Arc::new(Budget {
active: admissions.active.clone(),
settlement_gate: Mutex::new(Some(gate)),
..Default::default()
});
let service = charged_service(admissions.clone(), Some(budget.clone()), false);
let mut started = service
.start(request(&service, ClientTransport::HttpJson))
.await
.unwrap();
assert_eq!(
started.session.collect_uncommitted().await.unwrap().len(),
3
);
let mut commit = started.session.commit_downstream(Some(200));
assert!(futures::poll!(commit.as_mut()).is_pending());
drop(commit);
assert!(!started.session.is_finalized());
release
.send(())
.expect("buffered commit retains settlement");
started.session.detach_finalize().await;
assert_cleanup_completed(&admissions, &budget, &started.request_id);
});
}
#[test]
fn settlement_failure_keeps_provider_error_and_releases_concurrency_once() {
block_on(async {
for fail_settlement in [false, true] {
let admissions = Arc::new(Admissions::default());
let budget = Arc::new(Budget {
active: admissions.active.clone(),
fail_settlement,
..Default::default()
});
let service = charged_service(admissions.clone(), Some(budget.clone()), true);
let mut started = service
.start(request(&service, ClientTransport::WebSocket))
.await
.unwrap();
consume_charged_prefix(started.session.as_mut()).await;
assert!(matches!(
started.session.next_event().await,
Err(EngineError::Provider(error)) if error.kind() == ProviderErrorKind::InvalidRequest
));
assert!(started.session.is_finalized());
started.session.detach_finalize().await;
// Store 端口已接管精确费用后,结算错误不能改写 Provider 错误或触发第二次结算。
assert_cleanup_completed(&admissions, &budget, &started.request_id);
}
});
}
use async_trait::async_trait;
use bytes::Bytes;
use futures::{executor::block_on, future::BoxFuture};
use futures::{channel::oneshot, executor::block_on, future::BoxFuture};
use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy};
use gateway_core::engine::admission::{
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
@@ -245,31 +597,34 @@ use gateway_core::engine::continuation::{
};
use gateway_core::engine::execution::{
ClientApiKeyUsageSink, ClientTransport, DefaultExecutionService, ExecutionRequestMetadata,
ExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
StartExecution, StartProviderExecution, provider_failure_affects_circuit,
ExecutionService, ExecutionSession, ProviderCircuitDecision, ProviderCircuitError,
ProviderCircuitPort, StartExecution, StartProviderExecution, provider_failure_affects_circuit,
};
use gateway_core::engine::probe::{AccountProbe, AccountProbeErrorSource, AccountProbeRequest};
use gateway_core::engine::provider::{
Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities,
ProviderRegistry, ProviderRequest, ProviderRequestObservation, ProviderStream,
Provider, ProviderCallMetadata, ProviderRegistry, ProviderRequest, ProviderRequestObservation,
ProviderStream,
};
use gateway_core::engine::{
AttemptContext, AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization,
ModelRequestId, NewModelRequest, ProbeFailure, RecoveryReport,
AttemptContext, AttemptRecord, CommitRequirement, EngineError, ExecutionStore,
IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest, ProbeFailure,
RecoveryReport,
};
use gateway_core::error::{
ClientVisibleUpstreamResponse, GatewayErrorKind, ProviderError, ProviderErrorKind, StoreError,
StoreErrorKind,
};
use gateway_core::event::{GatewayEvent, ProviderEvent, ResponseMeta};
use gateway_core::metering::{Decimal, ProviderReportedCost};
use gateway_core::operation::{
GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, ProtocolPayload,
RawJsonPayload,
};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, PublicModelId, RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot,
UpstreamModelId,
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities,
ProviderCatalogGeneration, ProviderKind, ProviderModel, ProviderModelCapabilities,
PublicModelId, RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::runtime::RuntimeSnapshotHandle;
use gateway_core::upstream::{UpstreamSendState, UpstreamTransport};
@@ -11,8 +11,8 @@ use futures_timer::Delay;
use gateway_core::account::{AccountFeedbackStats, ProviderAccountId};
use gateway_core::engine::AttemptContext;
use gateway_core::engine::provider::{
EventStream, Provider, ProviderCallMetadata, ProviderCatalogGeneration,
ProviderModelCapabilities, ProviderRegistry, ProviderRequest, ProviderStream, RegistryError,
EventStream, Provider, ProviderCallMetadata, ProviderRegistry, ProviderRequest, ProviderStream,
RegistryError,
};
use gateway_core::error::{ProviderError, ProviderErrorKind};
use gateway_core::event::{
@@ -20,7 +20,10 @@ use gateway_core::event::{
TextDelta,
};
use gateway_core::operation::OperationKind;
use gateway_core::routing::{ModelCapabilities, ProviderKind, UpstreamModelId};
use gateway_core::routing::{
ModelCapabilities, ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderKind, ProviderModelCapabilities, UpstreamModelId,
};
use gateway_core::upstream::{UpstreamSendState, UpstreamTransport};
use serde_json::json;
@@ -84,9 +87,14 @@ fn registry_should_query_provider_compiled_model_capabilities() {
.expect("provider");
let registry = builder.build();
let provider = ProviderKind::new("openai").expect("provider kind");
let models = futures::executor::block_on(registry.query_model_capabilities(&provider))
let catalog: &dyn ProviderCatalogPort = &registry;
let models = futures::executor::block_on(catalog.query_model_capabilities(&provider))
.expect("live catalog");
assert_eq!(
catalog.catalog_generations(),
std::collections::BTreeMap::from([(provider, ProviderCatalogGeneration::default())]),
);
assert_eq!(models[0].upstream_model().as_str(), "live-model");
assert!(
models[0]
@@ -98,6 +106,75 @@ fn registry_should_query_provider_compiled_model_capabilities() {
);
}
struct CatalogProvider(Result<Vec<ProviderModelCapabilities>, ProviderErrorKind>);
#[async_trait]
impl Provider for CatalogProvider {
fn name(&self) -> &'static str {
"alpha"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::new(7)
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
self.0
.clone()
.map_err(|kind| ProviderError::new(kind, UpstreamSendState::NotSent))
}
async fn execute(
&self,
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
panic!("目录端口不得触发执行")
}
}
#[test]
fn registry_catalog_port_should_preserve_unknown_and_known_empty_catalogs() {
let provider = ProviderKind::new("alpha").expect("provider");
for (response, expected) in [
(
Err(ProviderErrorKind::Unavailable),
Err(ProviderCatalogUnavailable),
),
(Ok(Vec::new()), Ok(Vec::new())),
] {
let registry =
ProviderRegistry::new([Arc::new(CatalogProvider(response)) as Arc<dyn Provider>])
.expect("registry");
let catalog: &dyn ProviderCatalogPort = &registry;
assert_eq!(
futures::executor::block_on(catalog.query_model_capabilities(&provider)),
expected,
);
assert_eq!(
catalog.catalog_generations().get(&provider),
Some(&ProviderCatalogGeneration::new(7)),
"目录不可读也不能丢失注册身份和最近成功发布的代次",
);
}
}
#[test]
fn registry_catalog_port_should_report_unregistered_provider_as_unknown() {
let registry = ProviderRegistry::default();
let catalog: &dyn ProviderCatalogPort = &registry;
let provider = ProviderKind::new("missing").expect("provider");
assert_eq!(
futures::executor::block_on(catalog.query_model_capabilities(&provider)),
Err(ProviderCatalogUnavailable),
);
assert!(catalog.catalog_generations().is_empty());
}
struct DropLease(Arc<AtomicBool>);
impl Drop for DropLease {
@@ -2,16 +2,9 @@ use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use futures::executor::block_on;
use futures::future::BoxFuture;
use gateway_core::engine::AttemptContext;
use gateway_core::engine::provider::{
Provider, ProviderCatalogGeneration, ProviderModelCapabilities, ProviderRegistry,
ProviderRequest, ProviderStream,
};
use gateway_core::error::{ProviderError, ProviderErrorKind};
use gateway_core::operation::OperationKind;
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::{
@@ -20,10 +13,10 @@ use gateway_core::routing::snapshot::{
SnapshotProviderAccountFacts, SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort,
};
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ModelPresentation, ProviderKind, PublicModelId,
UpstreamModelId,
ConfigRevision, ModelCapabilities, ModelPresentation, ProviderCatalogGeneration,
ProviderCatalogPort, ProviderCatalogUnavailable, ProviderKind, ProviderModelCapabilities,
PublicModelId, UpstreamModelId,
};
use gateway_core::upstream::UpstreamSendState;
#[derive(Clone)]
struct TestSnapshotStore {
@@ -51,90 +44,72 @@ impl SnapshotStorePort for TestSnapshotStore {
}
}
struct PublishingCatalogProvider {
struct PublishingCatalog {
generation: AtomicU64,
queries: AtomicUsize,
}
enum TestCatalogProvider {
enum TestCatalog {
NoProviders,
Unavailable,
Empty,
}
#[async_trait]
impl Provider for TestCatalogProvider {
fn name(&self) -> &'static str {
"alpha"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::new(0)
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
impl ProviderCatalogPort for TestCatalog {
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
match self {
Self::Unavailable => Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
)),
Self::Empty => Ok(Vec::new()),
Self::NoProviders => BTreeMap::new(),
Self::Unavailable | Self::Empty => catalog_generations(0),
}
}
async fn execute(
fn query_model_capabilities(
&self,
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
))
_: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>> {
Box::pin(async move {
match self {
Self::NoProviders | Self::Unavailable => Err(ProviderCatalogUnavailable),
Self::Empty => Ok(Vec::new()),
}
})
}
}
#[async_trait]
impl Provider for PublishingCatalogProvider {
fn name(&self) -> &'static str {
"alpha"
fn catalog_generations(generation: u64) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
BTreeMap::from([(
ProviderKind::new("alpha").expect("provider"),
ProviderCatalogGeneration::new(generation),
)])
}
impl ProviderCatalogPort for PublishingCatalog {
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
catalog_generations(self.generation.load(Ordering::SeqCst))
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::new(self.generation.load(Ordering::SeqCst))
}
async fn query_model_capabilities(
fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
if self.queries.fetch_add(1, Ordering::SeqCst) == 0 {
self.generation.store(1, Ordering::SeqCst);
}
Ok(vec![
ProviderModelCapabilities::new(
UpstreamModelId::new("upstream-model").expect("model"),
ModelCapabilities::new(
std::collections::BTreeSet::from([OperationKind::Generate]),
_: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>> {
Box::pin(async move {
if self.queries.fetch_add(1, Ordering::SeqCst) == 0 {
self.generation.store(1, Ordering::SeqCst);
}
Ok(vec![
ProviderModelCapabilities::new(
UpstreamModelId::new("upstream-model").expect("model"),
ModelCapabilities::new(
std::collections::BTreeSet::from([OperationKind::Generate]),
None,
),
)
.with_presentation(ModelPresentation::new(
Some("Upstream Model".to_owned()),
None,
),
)
.with_presentation(ModelPresentation::new(
Some("Upstream Model".to_owned()),
None,
)),
])
}
async fn execute(
&self,
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
))
)),
])
})
}
}
@@ -150,11 +125,10 @@ fn compiler_should_reject_revision_changed_during_consistent_read() {
#[test]
fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
let providers =
ProviderRegistry::new([Arc::new(TestCatalogProvider::Unavailable) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
let compiler = RuntimeSnapshotCompiler::new(
Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))),
Arc::new(TestCatalog::Unavailable),
);
let snapshot = block_on(compiler.compile()).expect("compile snapshot");
let provider = ProviderKind::new("alpha").expect("provider");
@@ -170,11 +144,10 @@ fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
#[test]
fn known_empty_catalog_should_report_model_not_found() {
let providers =
ProviderRegistry::new([Arc::new(TestCatalogProvider::Empty) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
let compiler = RuntimeSnapshotCompiler::new(
Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))),
Arc::new(TestCatalog::Empty),
);
let snapshot = block_on(compiler.compile()).expect("compile empty catalog");
let error = snapshot
.plan(
@@ -199,18 +172,18 @@ fn known_empty_catalog_should_report_model_not_found() {
#[test]
fn compiler_retries_when_provider_publishes_catalog_during_compilation() {
let provider = Arc::new(PublishingCatalogProvider {
let catalog = Arc::new(PublishingCatalog {
generation: AtomicU64::new(0),
queries: AtomicUsize::new(0),
});
let providers =
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
let compiler = RuntimeSnapshotCompiler::new(
Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))),
catalog.clone(),
);
let snapshot = block_on(compiler.compile()).expect("stable catalog snapshot");
assert_eq!(provider.queries.load(Ordering::SeqCst), 2);
assert_eq!(catalog.queries.load(Ordering::SeqCst), 2);
assert_eq!(
snapshot
.provider_catalog_generations()
@@ -311,7 +284,7 @@ fn facts_revision(facts: &SnapshotFacts) -> ConfigRevision {
}
fn compiler(store: Arc<dyn SnapshotStorePort>) -> RuntimeSnapshotCompiler {
RuntimeSnapshotCompiler::new(store, ProviderRegistry::default())
RuntimeSnapshotCompiler::new(store, Arc::new(TestCatalog::NoProviders))
}
fn revision(value: u64) -> ConfigRevision {
@@ -1,30 +1,42 @@
use std::collections::BTreeMap;
use std::num::NonZeroU32;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures::channel::{mpsc, oneshot};
use futures::executor::block_on;
use futures::future::BoxFuture;
use gateway_core::account::{AccountSelectionPolicy, RotationStrategy};
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::{
RuntimeSnapshotCompiler, SnapshotAccountGroupFacts, SnapshotAccountGroupMemberFacts,
SnapshotClientPolicyFacts, SnapshotFacts, SnapshotProviderAccountFacts, SnapshotSettingsFacts,
SnapshotStoreError, SnapshotStorePort,
};
use gateway_core::routing::{ConfigRevision, RuntimeSnapshot};
use gateway_core::routing::{
AccountGroupId, ConfigRevision, ProviderCatalogGeneration, ProviderCatalogPort,
ProviderCatalogUnavailable, ProviderKind, ProviderModelCapabilities, RuntimeSnapshot,
};
use gateway_core::runtime::{
RuntimeSnapshotHandle, RuntimeSnapshotPublisher, SnapshotControl, SnapshotRevisionStream,
SnapshotSubscriptionError, SnapshotSubscriptionPort, runtime_revision_needs_refresh,
};
use gateway_core::task::WorkerKind;
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerKind, WorkerRunnable,
};
type ReadGate<T> = Arc<Mutex<Option<oneshot::Receiver<Result<T, SnapshotStoreError>>>>>;
#[derive(Clone)]
struct TestSnapshotStore {
facts: Arc<Mutex<Result<SnapshotFacts, SnapshotStoreError>>>,
current_revision: Arc<Mutex<Result<ConfigRevision, SnapshotStoreError>>>,
next_load: ReadGate<SnapshotFacts>,
next_revision: ReadGate<ConfigRevision>,
loads: Arc<AtomicUsize>,
}
impl TestSnapshotStore {
@@ -36,23 +48,45 @@ impl TestSnapshotStore {
Self {
facts: Arc::new(Mutex::new(facts)),
current_revision: Arc::new(Mutex::new(current_revision)),
next_load: Arc::default(),
next_revision: Arc::default(),
loads: Arc::default(),
}
}
}
impl SnapshotStorePort for TestSnapshotStore {
fn load_snapshot_facts(&self) -> BoxFuture<'_, Result<SnapshotFacts, SnapshotStoreError>> {
Box::pin(async move { self.facts.lock().expect("facts lock").clone() })
Box::pin(async move {
self.loads.fetch_add(1, Ordering::SeqCst);
let gate = self.next_load.lock().expect("load gate lock").take();
if let Some(gate) = gate {
return gate.await.expect("release load");
}
self.facts.lock().expect("facts lock").clone()
})
}
fn current_config_revision(&self) -> BoxFuture<'_, Result<ConfigRevision, SnapshotStoreError>> {
Box::pin(async move { self.current_revision.lock().expect("revision lock").clone() })
Box::pin(async move {
let gate = self
.next_revision
.lock()
.expect("revision gate lock")
.take();
if let Some(gate) = gate {
return gate.await.expect("release revision read");
}
self.current_revision.lock().expect("revision lock").clone()
})
}
}
#[derive(Default)]
struct TestSnapshotSubscriptions {
published: Mutex<Vec<ConfigRevision>>,
stream: Mutex<Option<SnapshotRevisionStream>>,
next_publish: Mutex<Option<oneshot::Receiver<()>>>,
}
impl SnapshotSubscriptionPort for TestSnapshotSubscriptions {
@@ -61,6 +95,10 @@ impl SnapshotSubscriptionPort for TestSnapshotSubscriptions {
revision: ConfigRevision,
) -> BoxFuture<'_, Result<(), SnapshotSubscriptionError>> {
Box::pin(async move {
let gate = self.next_publish.lock().expect("publish gate lock").take();
if let Some(gate) = gate {
gate.await.expect("release notification");
}
self.published
.lock()
.expect("published lock")
@@ -72,7 +110,14 @@ impl SnapshotSubscriptionPort for TestSnapshotSubscriptions {
fn subscribe_snapshot_revisions(
&self,
) -> BoxFuture<'_, Result<SnapshotRevisionStream, SnapshotSubscriptionError>> {
Box::pin(async move { Ok(Box::pin(futures::stream::empty()) as SnapshotRevisionStream) })
Box::pin(async move {
Ok(self
.stream
.lock()
.expect("subscription stream lock")
.take()
.unwrap_or_else(|| Box::pin(futures::stream::pending())))
})
}
}
@@ -187,6 +232,398 @@ fn publisher_should_contribute_reconciliation_and_subscription_workers() {
);
}
#[test]
fn overlapping_refreshes_should_not_restore_revoked_account_scope() {
block_on(async {
let (release, gate) = oneshot::channel();
let catalog = Arc::new(TestCatalog::default());
*catalog.next_query.lock().expect("catalog gate lock") = Some(gate);
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(10, true))));
let handle = RuntimeSnapshotHandle::new(empty_snapshot(9));
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(catalog_compiler(store.clone(), catalog.clone())),
handle.clone(),
Arc::new(TestSnapshotSubscriptions::default()),
);
let other_publisher = publisher.clone();
// 旧刷新已读完 revision 10 的完整事实,停在目录查询;不用 sleep 碰调度概率。
let mut old = Box::pin(publisher.refresh());
assert!(futures::poll!(old.as_mut()).is_pending());
assert_eq!(catalog.queries.load(Ordering::SeqCst), 1);
*store.facts.lock().expect("facts lock") = Ok(scoped_facts(11, false));
*store.current_revision.lock().expect("revision lock") = Ok(revision(11));
let mut new = Box::pin(other_publisher.refresh());
assert!(futures::poll!(new.as_mut()).is_pending());
assert_eq!(store.loads.load(Ordering::SeqCst), 1);
assert_eq!(handle.revision(), Some(revision(9)));
release.send(()).expect("release old catalog query");
assert_eq!(old.await.expect("old refresh"), revision(10));
let frozen_old = handle.acquire().expect("old request snapshot");
assert!(removed_account_allowed(&frozen_old));
assert_eq!(new.await.expect("new refresh"), revision(11));
let current = handle.acquire().expect("new request snapshot");
assert!(!removed_account_allowed(&current));
assert_eq!(current.revision(), revision(11));
assert!(removed_account_allowed(&frozen_old));
assert_eq!(frozen_old.revision(), revision(10));
});
}
#[test]
fn failed_committed_refresh_should_suspend_before_next_publication() {
block_on(async {
let (release, gate) = oneshot::channel();
let store = Arc::new(TestSnapshotStore::new(Ok(facts(11, 11))));
*store.next_load.lock().expect("load gate lock") = Some(gate);
let handle = RuntimeSnapshotHandle::new(empty_snapshot(9));
let subscriptions = Arc::new(TestSnapshotSubscriptions::default());
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(compiler(store.clone())),
handle.clone(),
subscriptions.clone(),
);
let other_publisher = publisher.clone();
let mut old = publisher.publish_committed(revision(10));
assert!(futures::poll!(old.as_mut()).is_pending());
let mut new = other_publisher.publish_committed(revision(11));
assert!(futures::poll!(new.as_mut()).is_pending());
assert_eq!(store.loads.load(Ordering::SeqCst), 1);
release
.send(Err(SnapshotStoreError::unavailable()))
.expect("release failed facts read");
old.await;
assert!(handle.acquire().is_err());
new.await;
assert_eq!(handle.revision(), Some(revision(11)));
assert_eq!(
*subscriptions.published.lock().expect("published lock"),
vec![revision(10), revision(11)],
);
});
}
#[test]
fn reconciliation_revision_failure_should_not_suspend_later_committed_refresh() {
block_on(async {
let (release, gate) = oneshot::channel();
let store = Arc::new(TestSnapshotStore::new(Ok(facts(11, 11))));
*store.next_revision.lock().expect("revision gate lock") = Some(gate);
let handle = RuntimeSnapshotHandle::new(empty_snapshot(9));
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(compiler(store.clone())),
handle.clone(),
Arc::new(TestSnapshotSubscriptions::default()),
);
let (task, context) = reconciliation_task(&publisher);
let mut reconcile = task.run_cycle(context);
assert!(futures::poll!(reconcile.as_mut()).is_pending());
let mut committed = publisher.publish_committed(revision(11));
assert!(futures::poll!(committed.as_mut()).is_pending());
assert_eq!(store.loads.load(Ordering::SeqCst), 0);
release
.send(Err(SnapshotStoreError::unavailable()))
.expect("release failed revision read");
assert!(reconcile.await.is_err());
assert!(handle.acquire().is_err());
committed.await;
assert_eq!(handle.revision(), Some(revision(11)));
});
}
#[test]
fn subscription_refresh_should_wait_for_inflight_compile_and_reload_authoritative_facts() {
block_on(async {
let (release, gate) = oneshot::channel();
let catalog = Arc::new(TestCatalog::default());
*catalog.next_query.lock().expect("catalog gate lock") = Some(gate);
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(10, true))));
let handle = RuntimeSnapshotHandle::new(empty_snapshot(9));
let (notifications, stream) = mpsc::unbounded();
let subscriptions = Arc::new(TestSnapshotSubscriptions {
stream: Mutex::new(Some(Box::pin(stream))),
..TestSnapshotSubscriptions::default()
});
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(catalog_compiler(store.clone(), catalog)),
handle.clone(),
subscriptions,
);
let task = publisher
.worker_contributions()
.expect("workers")
.into_iter()
.find_map(|contribution| match contribution {
WorkerContribution::Registration(registration) => match registration.runnable {
WorkerRunnable::Daemon { task, .. } => Some(task),
WorkerRunnable::Scheduled { .. } => None,
},
WorkerContribution::Disabled { .. } => None,
})
.expect("subscription task");
let cancellation = CancellationToken::new();
let mut daemon = task.run(cancellation.clone());
let mut old = Box::pin(publisher.refresh());
assert!(futures::poll!(old.as_mut()).is_pending());
*store.facts.lock().expect("facts lock") = Ok(scoped_facts(11, false));
// 通知只是提示,即使版本过期也要在取得发布权后重新读取 Store。
notifications
.unbounded_send(Ok(revision(3)))
.expect("notify");
assert!(futures::poll!(daemon.as_mut()).is_pending());
assert_eq!(store.loads.load(Ordering::SeqCst), 1);
assert_eq!(handle.revision(), Some(revision(9)));
release.send(()).expect("release old catalog query");
old.await.expect("old refresh");
assert!(futures::poll!(daemon.as_mut()).is_pending());
let current = handle.acquire().expect("subscription snapshot");
assert_eq!(current.revision(), revision(11));
assert!(!removed_account_allowed(&current));
cancellation.cancel();
daemon.await.expect("stop subscription");
});
}
#[test]
fn reconciliation_should_fail_closed_on_persisted_revision_rollback_and_recover() {
block_on(async {
let store = Arc::new(TestSnapshotStore::new(Ok(facts(7, 7))));
*store.facts.lock().expect("facts lock") = Err(SnapshotStoreError::unavailable());
let handle = RuntimeSnapshotHandle::new(empty_snapshot(8));
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(compiler(store.clone())),
handle.clone(),
Arc::new(TestSnapshotSubscriptions::default()),
);
let (task, context) = reconciliation_task(&publisher);
assert!(task.run_cycle(context.clone()).await.is_err());
assert!(handle.acquire().is_err());
*store.facts.lock().expect("facts lock") = Ok(facts(7, 7));
task.run_cycle(context.clone()).await.expect("recover");
assert_eq!(handle.revision(), Some(revision(7)));
// 有效的持久回退也必须发布,不能用 revision 数值单调性掩盖竞争。
*store.facts.lock().expect("facts lock") = Ok(facts(6, 6));
*store.current_revision.lock().expect("revision lock") = Ok(revision(6));
task.run_cycle(context).await.expect("publish rollback");
assert_eq!(handle.revision(), Some(revision(6)));
});
}
#[test]
fn catalog_only_reconciliation_should_keep_snapshot_on_failure_and_retry_generation() {
block_on(async {
let catalog = Arc::new(TestCatalog::default());
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(10, true))));
let handle = RuntimeSnapshotHandle::default();
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(catalog_compiler(store.clone(), catalog.clone())),
handle.clone(),
Arc::new(TestSnapshotSubscriptions::default()),
);
publisher.refresh().await.expect("initial snapshot");
let frozen = handle.acquire().expect("frozen snapshot");
let (task, context) = reconciliation_task(&publisher);
task.run_cycle(context.clone()).await.expect("unchanged");
assert_eq!(store.loads.load(Ordering::SeqCst), 1);
assert!(Arc::ptr_eq(
&frozen,
&handle.acquire().expect("unchanged snapshot")
));
catalog.generation.store(1, Ordering::SeqCst);
*store.facts.lock().expect("facts lock") = Err(SnapshotStoreError::unavailable());
assert!(task.run_cycle(context.clone()).await.is_err());
assert!(Arc::ptr_eq(
&frozen,
&handle.acquire().expect("retained snapshot")
));
*store.facts.lock().expect("facts lock") = Ok(scoped_facts(10, true));
task.run_cycle(context)
.await
.expect("retry catalog refresh");
let current = handle.acquire().expect("refreshed catalog");
assert_eq!(current.revision(), revision(10));
assert_eq!(
current
.provider_catalog_generations()
.values()
.copied()
.collect::<Vec<_>>(),
vec![ProviderCatalogGeneration::new(1)],
);
assert_eq!(
frozen
.provider_catalog_generations()
.values()
.copied()
.collect::<Vec<_>>(),
vec![ProviderCatalogGeneration::new(0)],
);
});
}
#[test]
fn dropping_inflight_refresh_should_release_publication_for_next_refresh() {
block_on(async {
let (release, gate) = oneshot::channel();
let store = Arc::new(TestSnapshotStore::new(Ok(facts(11, 11))));
*store.next_load.lock().expect("load gate lock") = Some(gate);
let handle = RuntimeSnapshotHandle::new(empty_snapshot(9));
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(compiler(store)),
handle.clone(),
Arc::new(TestSnapshotSubscriptions::default()),
);
let mut cancelled = Box::pin(publisher.refresh());
assert!(futures::poll!(cancelled.as_mut()).is_pending());
let mut next = Box::pin(publisher.refresh());
assert!(futures::poll!(next.as_mut()).is_pending());
drop(cancelled);
assert!(release.send(Ok(facts(10, 10))).is_err());
assert_eq!(next.await.expect("next refresh"), revision(11));
assert_eq!(handle.revision(), Some(revision(11)));
});
}
#[test]
fn pending_notification_should_not_block_next_local_refresh() {
block_on(async {
let (release, gate) = oneshot::channel();
let store = Arc::new(TestSnapshotStore::new(Ok(facts(10, 10))));
let handle = RuntimeSnapshotHandle::new(empty_snapshot(9));
let subscriptions = Arc::new(TestSnapshotSubscriptions {
next_publish: Mutex::new(Some(gate)),
..TestSnapshotSubscriptions::default()
});
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(compiler(store.clone())),
handle.clone(),
subscriptions,
);
let mut committed = publisher.publish_committed(revision(10));
assert!(futures::poll!(committed.as_mut()).is_pending());
assert_eq!(handle.revision(), Some(revision(10)));
*store.facts.lock().expect("facts lock") = Ok(facts(11, 11));
let mut next = Box::pin(publisher.refresh());
assert_eq!(
futures::poll!(next.as_mut()),
std::task::Poll::Ready(Ok(revision(11))),
);
release.send(()).expect("release notification");
committed.await;
assert_eq!(handle.revision(), Some(revision(11)));
});
}
fn reconciliation_task(
publisher: &RuntimeSnapshotPublisher,
) -> (Box<dyn ScheduledTask>, WorkerCycleContext) {
publisher
.worker_contributions()
.expect("workers")
.into_iter()
.find_map(|contribution| match contribution {
WorkerContribution::Registration(registration) => match registration.runnable {
WorkerRunnable::Scheduled { task, .. } => Some((
task,
WorkerCycleContext::new(registration.id, None, CancellationToken::new()),
)),
WorkerRunnable::Daemon { .. } => None,
},
WorkerContribution::Disabled { .. } => None,
})
.expect("reconciliation task")
}
#[derive(Default)]
struct TestCatalog {
generation: AtomicU64,
queries: AtomicUsize,
next_query: Mutex<Option<oneshot::Receiver<()>>>,
}
impl ProviderCatalogPort for TestCatalog {
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
BTreeMap::from([(
ProviderKind::new("audit").expect("provider"),
ProviderCatalogGeneration::new(self.generation.load(Ordering::SeqCst)),
)])
}
fn query_model_capabilities(
&self,
_: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>> {
Box::pin(async move {
self.queries.fetch_add(1, Ordering::SeqCst);
let gate = self.next_query.lock().expect("catalog gate lock").take();
if let Some(gate) = gate {
gate.await.expect("release catalog query");
}
Ok(Vec::new())
})
}
}
fn catalog_compiler(
store: Arc<dyn SnapshotStorePort>,
catalog: Arc<TestCatalog>,
) -> RuntimeSnapshotCompiler {
RuntimeSnapshotCompiler::new(store, catalog)
}
fn scoped_facts(value: u64, allow_removed: bool) -> SnapshotFacts {
let group = AccountGroupId::new(format!("grp_{:032x}", 1)).expect("group");
let keep = ProviderAccountId::new("acct_audit_keep").expect("kept account");
let removed = ProviderAccountId::new("acct_audit_removed").expect("removed account");
let mut memberships = vec![SnapshotAccountGroupMemberFacts::new(
group.clone(),
keep.clone(),
)];
if allow_removed {
memberships.push(SnapshotAccountGroupMemberFacts::new(
group.clone(),
removed.clone(),
));
}
SnapshotFacts::new(
revision(value),
revision(value),
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None),
vec![SnapshotClientPolicyFacts::new(
ClientApiKeyId::new("key_audit_synthetic").expect("key ID"),
PlaintextClientApiKey::new("sk_audit_synthetic_not_a_real_key").expect("synthetic key"),
vec![group.clone()],
RateLimits::unlimited(),
)],
vec![SnapshotAccountGroupFacts::new(
group,
"Synthetic group".to_owned(),
true,
)],
vec![
SnapshotProviderAccountFacts::new(keep, "audit"),
SnapshotProviderAccountFacts::new(removed, "audit"),
],
memberships,
)
}
fn removed_account_allowed(snapshot: &RuntimeSnapshot) -> bool {
snapshot
.client_policies()
.next()
.expect("client policy")
.account_scope()
.allows(&ProviderAccountId::new("acct_audit_removed").expect("removed account"))
}
fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts {
SnapshotFacts::new(
revision(config_revision),
@@ -212,7 +649,7 @@ fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts
}
fn compiler(store: Arc<dyn SnapshotStorePort>) -> RuntimeSnapshotCompiler {
RuntimeSnapshotCompiler::new(store, ProviderRegistry::default())
catalog_compiler(store, Arc::new(TestCatalog::default()))
}
fn empty_snapshot(value: u64) -> RuntimeSnapshot {
@@ -476,87 +476,6 @@ impl AccountStore for PgAdminAccountStore {
self.usage_by_windows(windows).await
}
async fn list_credentials(
&self,
provider_kind: &ProviderKind,
query: CredentialListQuery,
) -> AdminStoreResult<CredentialPage> {
let (control_plane, accounts) = futures::try_join!(
self.control_plane.load_control_plane(),
self.accounts
.list_provider_accounts(Some(provider_kind.as_str()), true),
)
.map_err(|error| admin_store_error(ENTITY, error))?;
let mut accounts = accounts
.into_iter()
.filter(|account| account.provider_kind == provider_kind.as_str())
.filter(|account| {
query
.credential_state
.as_ref()
.is_none_or(|expected| expected.matches(account.credential_state))
})
.filter(|account| {
query
.enabled
.is_none_or(|enabled| account.enabled == enabled)
})
.filter(|account| {
let CredentialListWindow::Page {
cursor: Some(cursor),
..
} = &query.window
else {
return true;
};
account.created_at > cursor.created_at
|| (account.created_at == cursor.created_at
&& account.id.as_str() > cursor.account_id.as_str())
})
.collect::<Vec<_>>();
if matches!(&query.window, CredentialListWindow::Page { .. }) {
accounts.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.id.cmp(&right.id))
});
}
let next_cursor = match query.window {
CredentialListWindow::All => None,
CredentialListWindow::Page { page_size, .. } => {
let page_size = usize::from(page_size.get());
let has_more = accounts.len() > page_size;
accounts.truncate(page_size);
has_more
.then(|| accounts.last())
.flatten()
.map(|account| {
Ok(CredentialCursor {
created_at: account.created_at,
account_id: CoreProviderAccountId::new(account.id.clone()).map_err(
|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted Provider account ID is invalid",
)
},
)?,
})
})
.transpose()?
}
};
Ok(CredentialPage {
config_revision: admin_revision(control_plane.settings.config_revision)?,
items: accounts
.into_iter()
.map(admin_account_record)
.collect::<AdminStoreResult<Vec<_>>>()?,
next_cursor,
})
}
async fn credential_details(
&self,
provider_kind: &ProviderKind,
@@ -21,9 +21,8 @@ use gateway_admin::{
CostCoverage as AdminCostCoverage, DecimalAmount as AdminDecimalAmount, TimeRange,
},
provider_credentials::{
AuthorizationCommit, AuthorizationCredentialCommit, CredentialCursor,
CredentialDetails, CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialListWindow, CredentialMutationResult, CredentialPage,
AuthorizationCommit, AuthorizationCredentialCommit, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
CredentialRotationCommit, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExportCredentialInput,
},
@@ -16,8 +16,7 @@ use gateway_admin::{
observability::TimeRange,
provider_credentials::{
AuthorizationCommit, AuthorizationCredentialCommit, AuthorizationMutationTarget,
AuthorizationOwnerBinding, CredentialListQuery, CredentialListWindow,
CredentialStateFilter, PendingAuthorizationMutation, PreparedCredentialCreate,
AuthorizationOwnerBinding, PendingAuthorizationMutation, PreparedCredentialCreate,
ProviderDocument,
},
},
@@ -776,83 +775,6 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
database.close().await;
}
#[tokio::test]
async fn terminal_credential_list_preserves_grouped_filters_and_unpaged_collections() {
let Some(database) = TestDatabase::create("provider_credential_terminal_windows").await else {
return;
};
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_kind = "xai".to_owned();
credential.credential_state = match index {
0 => CredentialState::Expired,
1 => CredentialState::Banned,
2 => CredentialState::Invalid,
_ => CredentialState::Ready,
};
repository
.insert_provider_account(credential)
.await
.expect("insert xAI credential fixture");
}
let store = admin_account_store(&database.pool);
let provider = ProviderKind::new("xai").expect("xAI Provider kind");
let complete = store
.list_credentials(
&provider,
CredentialListQuery {
credential_state: None,
enabled: None,
window: CredentialListWindow::All,
},
)
.await
.expect("unpaged credential collection");
assert_eq!(complete.items.len(), 205);
assert!(complete.next_cursor.is_none());
let page = store
.list_credentials(
&provider,
CredentialListQuery {
credential_state: None,
enabled: None,
window: CredentialListWindow::Page {
cursor: None,
page_size: PageSize::new(200).expect("page size"),
},
},
)
.await
.expect("paged credential collection");
assert_eq!(page.items.len(), 200);
assert!(page.next_cursor.is_some());
let invalid_group = store
.list_credentials(
&provider,
CredentialListQuery {
credential_state: Some(CredentialStateFilter::AnyOf(vec![
CredentialState::Expired,
CredentialState::Banned,
CredentialState::Invalid,
])),
enabled: None,
window: CredentialListWindow::All,
},
)
.await
.expect("grouped invalid credential filter");
assert_eq!(invalid_group.items.len(), 3);
database.close().await;
}
#[tokio::test]
async fn terminal_admin_usage_chunks_large_selections_and_preserves_exact_costs() {
let Some(database) = TestDatabase::create("provider_account_terminal_usage").await else {
@@ -9,11 +9,10 @@ use chrono::{DateTime, Utc};
use gateway_core::account::{
CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
};
use gateway_core::engine::provider::ProviderCatalogGeneration;
use gateway_core::provider_ports::{
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogScope,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use gateway_core::routing::{ProviderCatalogGeneration, ProviderKind, UpstreamModelId};
use secrecy::ExposeSecret;
use thiserror::Error;
use tokio::sync::Notify;
@@ -12,8 +12,7 @@ use futures::{StreamExt, future::BoxFuture};
use gateway_core::account::{AccountFeedbackStats, ProviderAccount};
use gateway_core::engine::continuation::{ContinuationBinding, NativeContinuationScope};
use gateway_core::engine::provider::{
ContinuationRequestObservation, EventStream, Provider, ProviderCallMetadata,
ProviderCatalogGeneration, ProviderModelCapabilities, ProviderRequest,
ContinuationRequestObservation, EventStream, Provider, ProviderCallMetadata, ProviderRequest,
ProviderRequestObservation, ProviderSelectionObservation, ProviderStream,
};
use gateway_core::engine::{AttemptContext, AttemptTransport, ContinuationAttempt};
@@ -34,7 +33,8 @@ use gateway_core::operation::{
};
use gateway_core::provider_ports::ProviderSessionAffinityKey;
use gateway_core::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderCatalogGeneration,
ProviderKind, ProviderModelCapabilities, UpstreamModelId,
};
use gateway_core::task::{
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
@@ -1,34 +1,9 @@
use super::*;
#[test]
fn reqwest_client_should_keep_http2_fallback_hot_while_idle() {
let source = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/transport/client.rs"),
)
.expect("transport client source");
for setting in [
".pool_idle_timeout(None::<Duration>)",
".http2_keep_alive_interval(Duration::from_secs(30))",
".http2_keep_alive_timeout(Duration::from_secs(5))",
".http2_keep_alive_while_idle(true)",
] {
assert!(source.contains(setting), "missing HTTP/2 setting {setting}");
}
assert!(!source.contains(".http1_only()"));
}
#[test]
fn endpoints_should_join_backend_paths() {
assert_eq!(
provider_openai::transport::endpoint_url("https://api.example.com/", "/codex/responses"),
"https://api.example.com/codex/responses"
);
}
#[test]
fn custom_ca_should_report_environment_cache_key_consistently() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_CUSTOM_CA_CACHE_KEY_CASE";
const CASE_COMPLETED: &str = "custom-ca-cache-key-case-completed:";
const SSL_CERT_PATH: &str = "/tmp/codex-proxy-ssl-cert-file.pem";
const CODEX_CA_PATH: &str = "/tmp/codex-proxy-codex-ca-certificate.pem";
@@ -50,6 +25,7 @@ fn custom_ca_should_report_environment_cache_key_consistently() {
provider_openai::transport::tls::custom_ca_env_cache_key(),
expected
);
println!("\n{CASE_COMPLETED}{case}");
return;
}
@@ -81,7 +57,7 @@ fn custom_ca_should_report_environment_cache_key_consistently() {
let mut command = Command::new(&current_exe);
command
.arg("--exact")
.arg("custom_ca_should_report_environment_cache_key_consistently")
.arg("transport::http_client::custom_ca_should_report_environment_cache_key_consistently")
.arg("--nocapture")
.env(CASE_ENV, case)
.env_remove(provider_openai::transport::tls::CODEX_CA_CERT_ENV)
@@ -100,6 +76,14 @@ fn custom_ca_should_report_environment_cache_key_consistently() {
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
// libtest 匹配零个测试也返回成功;完成标记证明该环境分支执行了生产断言。
let stdout = String::from_utf8_lossy(&output.stdout);
let completed = format!("{CASE_COMPLETED}{case}");
assert_eq!(
stdout.lines().filter(|line| *line == completed).count(),
1,
"isolated custom CA case {case} did not complete exactly once\nstdout:\n{stdout}"
);
}
}
@@ -563,8 +547,11 @@ async fn build_reqwest_client_should_reuse_cached_connection_pool() {
});
let url = format!("http://{addr}/reuse");
let client = reqwest::Client::builder().no_proxy().build().unwrap();
// 前后分别取得生产缓存中的客户端,避免只验证单个 reqwest client 自身能复用连接。
// 本地服务使用 HTTP/1.1;该断言不代表已验证 HTTP/2 的 idle ping 或保活间隔。
let client = provider_openai::transport::build_reqwest_client().unwrap();
client.get(&url).send().await.unwrap().text().await.unwrap();
let client = provider_openai::transport::build_reqwest_client().unwrap();
client.get(&url).send().await.unwrap().text().await.unwrap();
assert!(server.await.unwrap());
+1 -1
View File
@@ -34,7 +34,7 @@ zeroize = { version = "1.8.2", features = ["derive"] }
[dev-dependencies]
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
tokio = { version = "1.52.3", features = ["macros", "rt"] }
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
wiremock = "0.6.5"
[[test]]
@@ -11,11 +11,10 @@ use gateway_core::account::{
AccountQuotaSignals, CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount,
ProviderAccountId, QuotaObservation, QuotaState,
};
use gateway_core::engine::provider::ProviderCatalogGeneration;
use gateway_core::provider_ports::{
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCatalogScope,
};
use gateway_core::routing::ProviderKind;
use gateway_core::routing::{ProviderCatalogGeneration, ProviderKind};
use tokio::sync::Mutex;
use super::repository::{GrokCredentialRepository, LoadedGrokCredential};
@@ -902,42 +901,6 @@ impl GrokCredentialCatalogService {
ProviderCatalogGeneration::new(published.generation)
}
pub async fn fetch_seed(
&self,
access_token: SecretValue,
user_id: SecretValue,
email: Option<SecretValue>,
) -> Result<GrokCredentialCatalogSeed, GrokCredentialCatalogError> {
let session =
GrokModelCatalogSession::new(access_token, user_id, email, self.wire_profile.clone())
.map_err(|_| GrokCredentialCatalogError::InvalidCredentialData)?;
let snapshot = self
.client
.fetch(&session)
.await
.map_err(|_| GrokCredentialCatalogError::Upstream)?;
GrokCredentialCatalogSeed::from_snapshot(&snapshot)
}
pub async fn cache_seed(
&self,
account_id: &ProviderAccountId,
seed: GrokCredentialCatalogSeed,
) -> Result<(), GrokCredentialCatalogError> {
let account = self
.repository
.load_current(account_id)
.await
.map_err(|_| GrokCredentialCatalogError::Store)?
.account;
let scope = GrokCatalogScope::for_account(&account)
.map_err(|_| GrokCredentialCatalogError::InvalidCredentialData)?;
self.cache
.replace(GrokPlanCatalog::new(scope, Utc::now(), seed))
.await
.map_err(|_| GrokCredentialCatalogError::Cache)
}
/// 优先读取套餐目录 cache;缺失时才用当前账号所属套餐的有限候选集实时填充。
pub async fn cached_or_refresh_account_catalog(
&self,
@@ -36,12 +36,19 @@ const MAX_AUDIENCES: usize = 16;
/// 使用官方 JWKS 与 user-info endpoint 的严格生产 verifier。
pub struct ReqwestOidcTokenVerifier {
client: Client,
egress: crate::transport::egress::EgressCache<
Option<gateway_core::account::OutboundProxy>,
OidcEgress,
>,
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
jwks_cache: Mutex<JwksCache>,
cache_ttl: Duration,
}
struct OidcEgress {
client: Client,
jwks_cache: Mutex<JwksCache>,
}
impl ReqwestOidcTokenVerifier {
/// 构建 verifier;使用显式 endpoint policy 与 JWKS cache TTL,只允许访问
/// `auth.x.ai` 官方验证端点。
@@ -53,11 +60,16 @@ impl ReqwestOidcTokenVerifier {
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
cache_ttl: Duration,
) -> Result<Self, crate::transport::GrokReqwestTransportBuildError> {
let client = endpoint_policy.build_oauth_client(Some(REQUEST_TIMEOUT))?;
let client = endpoint_policy.build_oauth_client(Some(REQUEST_TIMEOUT), None)?;
Ok(Self {
client,
egress: crate::transport::egress::EgressCache::with_entry(
None,
OidcEgress {
client,
jwks_cache: Mutex::new(JwksCache::default()),
},
),
endpoint_policy,
jwks_cache: Mutex::new(JwksCache::default()),
cache_ttl,
})
}
@@ -66,12 +78,14 @@ impl ReqwestOidcTokenVerifier {
&self,
context: TokenVerificationContext<'_>,
candidate: TokenCandidate<'_>,
egress: &OidcEgress,
) -> Result<VerificationEvidence, VerificationFailure> {
self.validate_context(&context)?;
match context.flow() {
VerificationFlow::AuthorizationCode => self.verify_id_token(&context, candidate).await,
VerificationFlow::AuthorizationCode => {
self.verify_id_token(&context, candidate, egress).await
}
VerificationFlow::CredentialImport | VerificationFlow::CredentialImportRefreshed => {
self.verify_userinfo(&context, candidate).await
self.verify_userinfo(&context, candidate, egress).await
}
}
}
@@ -119,9 +133,10 @@ impl ReqwestOidcTokenVerifier {
&self,
context: &TokenVerificationContext<'_>,
candidate: TokenCandidate<'_>,
egress: &OidcEgress,
) -> Result<VerificationEvidence, VerificationFailure> {
let subject = self
.verify_signed_id_token(context, &candidate, true)
.verify_signed_id_token(context, &candidate, true, egress)
.await?;
Ok(VerificationEvidence::id_token(subject))
}
@@ -131,6 +146,7 @@ impl ReqwestOidcTokenVerifier {
context: &TokenVerificationContext<'_>,
candidate: &TokenCandidate<'_>,
require_nonce: bool,
egress: &OidcEgress,
) -> Result<String, VerificationFailure> {
let token = candidate
.id_token()
@@ -145,7 +161,7 @@ impl ReqwestOidcTokenVerifier {
return Err(VerificationFailure::Rejected);
}
let kid = header.kid.as_deref().ok_or(VerificationFailure::Rejected)?;
let key = self.decoding_key(context.jwks_uri(), kid).await?;
let key = self.decoding_key(context.jwks_uri(), kid, egress).await?;
let mut validation = Validation::new(Algorithm::ES256);
validation.leeway = 0;
@@ -187,8 +203,11 @@ impl ReqwestOidcTokenVerifier {
&self,
context: &TokenVerificationContext<'_>,
candidate: TokenCandidate<'_>,
egress: &OidcEgress,
) -> Result<VerificationEvidence, VerificationFailure> {
let subject = self.fetch_userinfo_subject(context, &candidate).await?;
let subject = self
.fetch_userinfo_subject(context, &candidate, egress)
.await?;
Ok(VerificationEvidence::user_info(subject))
}
@@ -196,12 +215,13 @@ impl ReqwestOidcTokenVerifier {
&self,
context: &TokenVerificationContext<'_>,
candidate: &TokenCandidate<'_>,
egress: &OidcEgress,
) -> Result<String, VerificationFailure> {
let access_token = candidate.access_token().expose();
if !valid_bearer_token(access_token) {
return Err(VerificationFailure::Rejected);
}
let response = self
let response = egress
.client
.get(context.userinfo_endpoint().clone())
.header(ACCEPT, "application/json")
@@ -232,13 +252,14 @@ impl ReqwestOidcTokenVerifier {
&self,
jwks_uri: &Url,
kid: &str,
egress: &OidcEgress,
) -> Result<DecodingKey, VerificationFailure> {
if !valid_key_id(kid) {
return Err(VerificationFailure::Rejected);
}
// 持锁完成获取,保证冷启动和轮换时每个进程只有一个在途 JWKS 请求。
let mut cache = self.jwks_cache.lock().await;
// 每个出口持锁获取,冷启动和轮换共用单飞;不同出口互不阻塞。
let mut cache = egress.jwks_cache.lock().await;
let cache_is_fresh = cache.uri.as_ref() == Some(jwks_uri)
&& cache
.fetched_at
@@ -249,7 +270,7 @@ impl ReqwestOidcTokenVerifier {
}
// fresh cache 未命中 kid 时只强制刷新一次,以支持官方密钥轮换。
let keys = self.fetch_jwks(jwks_uri).await?;
let keys = self.fetch_jwks(jwks_uri, &egress.client).await?;
cache.replace(jwks_uri.clone(), keys);
return cache
.keys
@@ -259,7 +280,7 @@ impl ReqwestOidcTokenVerifier {
}
// 过期缓存不会在网络或协议失败时作为 stale fallback 使用。
let keys = self.fetch_jwks(jwks_uri).await?;
let keys = self.fetch_jwks(jwks_uri, &egress.client).await?;
cache.replace(jwks_uri.clone(), keys);
cache
.keys
@@ -271,12 +292,12 @@ impl ReqwestOidcTokenVerifier {
async fn fetch_jwks(
&self,
jwks_uri: &Url,
client: &Client,
) -> Result<HashMap<String, CachedJwk>, VerificationFailure> {
if !self.endpoint_policy.validate_jwks(jwks_uri) {
return Err(VerificationFailure::Rejected);
}
let response = self
.client
let response = client
.get(jwks_uri.clone())
.header(ACCEPT, "application/json")
.send()
@@ -304,23 +325,24 @@ impl TokenVerifier for ReqwestOidcTokenVerifier {
candidate: TokenCandidate<'a>,
) -> VerificationFuture<'a> {
Box::pin(async move {
if let Some(proxy) = context.outbound_proxy() {
let client = crate::transport::network::account_proxy_client(
&self.client,
Some(proxy),
Some(REQUEST_TIMEOUT),
)
.map_err(|_| VerificationFailure::Unavailable)?;
let scoped = Self {
client,
endpoint_policy: Arc::clone(&self.endpoint_policy),
jwks_cache: Mutex::new(JwksCache::default()),
cache_ttl: self.cache_ttl,
};
scoped.verify_inner(context, candidate).await
} else {
self.verify_inner(context, candidate).await
}
self.validate_context(&context)?;
let entry = self
.egress
.entry(context.outbound_proxy().cloned())
.ok_or(VerificationFailure::Unavailable)?;
let egress = entry
.get_or_try_init(|| async {
let client = self
.endpoint_policy
.build_oauth_client(Some(REQUEST_TIMEOUT), context.outbound_proxy())
.map_err(|_| VerificationFailure::Unavailable)?;
Ok::<_, VerificationFailure>(OidcEgress {
client,
jwks_cache: Mutex::new(JwksCache::default()),
})
})
.await?;
self.verify_inner(context, candidate, egress).await
})
}
}
@@ -718,9 +718,6 @@ impl GrokCredentialRefreshService {
let account_id = credential.account_id.clone();
let access_expires_at = refreshed_access_token_expiry(tokens.expires_in)
.ok_or(GrokCredentialRefreshError::InvalidRefreshResponse)?;
let access_token = tokens.access_token.clone();
let subject = credential.subject.clone();
let email = credential.email.clone();
let record = match self
.repository
.rotate_oauth_credential(&RotateGrokCredential {
@@ -754,17 +751,9 @@ impl GrokCredentialRefreshService {
Err(error) => return Err(error.into()),
};
if let Ok(seed) = self
.catalog
.fetch_seed(
access_token,
SecretValue::new(subject),
email.map(SecretValue::new),
)
.await
{
let _ = self.catalog.cache_seed(&account_id, seed).await;
}
// 凭据提交后重新读取账号事实;刷新期间切换出口不会改变 credential CAS,
// 但后续目录请求必须使用已提交的当前出口,不能复用旧的 token-only 请求。
let _ = self.catalog.refresh_account_catalog(&account_id).await;
let _ = self
.credential_state
.clear_refresh_backoff(&account_id)
@@ -112,7 +112,10 @@ impl<'a> TokenVerificationContext<'a> {
}
}
pub(crate) fn with_outbound_proxy(
/// 绑定 OAuth owner 选择的账号出口,供公开的 [`TokenVerifier`] 实现遵守同一代理合同。
/// 出口只决定路由,不改变 issuer、JWKS、签名和 nonce 的信任校验。
#[must_use]
pub fn with_outbound_proxy(
mut self,
proxy: Option<&'a gateway_core::account::OutboundProxy>,
) -> Self {
@@ -120,7 +123,9 @@ impl<'a> TokenVerificationContext<'a> {
self
}
pub(crate) fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
/// 返回本次验证的显式出口;`None` 表示直连,不得继承环境代理。
#[must_use]
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
self.outbound_proxy
}
@@ -12,8 +12,7 @@ use gateway_core::account::{
};
use gateway_core::engine::continuation::ContinuationBinding;
use gateway_core::engine::provider::{
EventStream, Provider, ProviderCallMetadata, ProviderCatalogGeneration,
ProviderModelCapabilities, ProviderRequest, ProviderRequestObservation,
EventStream, Provider, ProviderCallMetadata, ProviderRequest, ProviderRequestObservation,
ProviderSelectionObservation, ProviderStream,
};
use gateway_core::engine::{AttemptContext, ContinuationAttempt};
@@ -29,8 +28,8 @@ use gateway_core::operation::{
Feature, GenerateRequest, Operation, OperationKind, ProviderSessionState,
};
use gateway_core::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, SupportLevel,
UpstreamModelId,
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderCatalogGeneration,
ProviderKind, ProviderModelCapabilities, SupportLevel, UpstreamModelId,
};
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDefinitionError, WorkerId,
@@ -0,0 +1,53 @@
//! 出口状态的有界生命周期;client 初始化与 JWKS 单飞共享同一淘汰边界。
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tokio::sync::OnceCell;
pub(crate) const MAX_CACHED_EGRESS_STATES: usize = 64;
type Entries<K, V> = VecDeque<(K, Arc<OnceCell<V>>)>;
pub(crate) struct EgressCache<K, V> {
entries: Mutex<Entries<K, V>>,
}
impl<K: PartialEq, V> EgressCache<K, V> {
pub(crate) fn new() -> Self {
Self {
entries: Mutex::new(VecDeque::new()),
}
}
pub(crate) fn with_entry(key: K, value: V) -> Self {
Self {
entries: Mutex::new(VecDeque::from([(
key,
Arc::new(OnceCell::new_with(Some(value))),
)])),
}
}
/// 初始化或 JWKS 验证期间持有 cell,避免淘汰后产生第二个单飞 owner。
/// 推理初始化完成后仅保留 client clone;淘汰不会中断旧出口的在途请求。
pub(crate) fn entry(&self, key: K) -> Option<Arc<OnceCell<V>>> {
let mut entries = self.entries.lock().ok()?;
if let Some(index) = entries.iter().position(|(candidate, _)| candidate == &key) {
let entry = entries.remove(index)?;
let cell = Arc::clone(&entry.1);
entries.push_back(entry);
return Some(cell);
}
if entries.len() == MAX_CACHED_EGRESS_STATES {
// 满载且全部在用时拒绝新出口;不扩容,也不破坏既有出口的单飞。
let idle = entries
.iter()
.position(|(_, value)| Arc::strong_count(value) == 1)?;
entries.remove(idle);
}
let cell = Arc::new(OnceCell::new());
entries.push_back((key, Arc::clone(&cell)));
Some(cell)
}
}
@@ -4,6 +4,7 @@ pub(crate) mod canonical;
pub(crate) mod catalog;
pub(crate) mod compaction;
pub(crate) mod config;
pub(crate) mod egress;
pub(crate) mod headers;
mod inference;
pub(crate) mod network;
@@ -1,7 +1,6 @@
//! Explicit account egress with redirects and business retries disabled.
use gateway_core::diagnostics::{StreamCapture, StreamFormat, TraceContext};
use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::io;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
@@ -34,7 +33,9 @@ use gateway_core::error::{ClientVisibleUpstreamError, OpaqueUpstreamValue, Provi
use gateway_core::event::UpstreamHttpVersion;
use gateway_core::upstream::UpstreamSendState;
use super::egress::{EgressCache, MAX_CACHED_EGRESS_STATES};
use super::{GrokQuotaFailureKind, classify_grok_quota_failure, scrub_account_fingerprints};
use gateway_core::account::OutboundProxy;
pub(crate) const OFFICIAL_OAUTH_HOST: &str = "auth.x.ai";
const OFFICIAL_INFERENCE_HOST: &str = "cli-chat-proxy.grok.com";
@@ -167,10 +168,12 @@ pub trait GrokEndpointPolicy: fmt::Debug + Send + Sync {
fn build_oauth_client(
&self,
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError>;
fn build_inference_client(
&self,
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError>;
fn validate_oauth(&self, url: &Url) -> bool;
fn validate_inference(&self, url: &Url) -> bool;
@@ -187,15 +190,21 @@ impl GrokEndpointPolicy for OfficialGrokEndpointPolicy {
fn build_oauth_client(
&self,
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
build_official_client(GrokDnsResolutionPolicy::official_oauth(), timeout)
build_official_client(GrokDnsResolutionPolicy::official_oauth(), timeout, proxy)
}
fn build_inference_client(
&self,
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
build_official_client(GrokDnsResolutionPolicy::official_inference(), timeout)
build_official_client(
GrokDnsResolutionPolicy::official_inference(),
timeout,
proxy,
)
}
fn validate_oauth(&self, url: &Url) -> bool {
@@ -229,7 +238,7 @@ impl GrokEndpointPolicy for OfficialGrokEndpointPolicy {
/// 官方 OAuth HTTP transport。只允许 `auth.x.ai:443`。
pub struct ReqwestOAuthTransport {
client: Client,
clients: EgressCache<Option<OutboundProxy>, Client>,
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
}
@@ -242,9 +251,9 @@ impl ReqwestOAuthTransport {
pub fn new(
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
) -> Result<Self, GrokReqwestTransportBuildError> {
let client = endpoint_policy.build_oauth_client(Some(OAUTH_REQUEST_TIMEOUT))?;
let client = endpoint_policy.build_oauth_client(Some(OAUTH_REQUEST_TIMEOUT), None)?;
Ok(Self {
client,
clients: EgressCache::with_entry(None, client),
endpoint_policy,
})
}
@@ -262,18 +271,24 @@ impl fmt::Debug for ReqwestOAuthTransport {
impl OAuthHttpTransport for ReqwestOAuthTransport {
fn execute(&self, request: OAuthHttpRequest) -> TransportFuture<'_> {
let client = self.client.clone();
let endpoint_policy = self.endpoint_policy.clone();
Box::pin(async move {
if !endpoint_policy.validate_oauth(request.url()) {
return Err(TransportFailure::new(TransportFailureKind::NotSent));
}
let client = account_proxy_client(
&client,
request.outbound_proxy.as_ref(),
Some(OAUTH_REQUEST_TIMEOUT),
)
.map_err(|_| TransportFailure::new(TransportFailureKind::NotSent))?;
let entry = self
.clients
.entry(request.outbound_proxy.clone())
.ok_or_else(|| TransportFailure::new(TransportFailureKind::NotSent))?;
let client = entry
.get_or_try_init(|| async {
endpoint_policy.build_oauth_client(
Some(OAUTH_REQUEST_TIMEOUT),
request.outbound_proxy.as_ref(),
)
})
.await
.map_err(|_| TransportFailure::new(TransportFailureKind::NotSent))?;
let mut builder = match request.method() {
HttpMethod::Get => client.get(request.url().clone()),
HttpMethod::Post => client.post(request.url().clone()),
@@ -305,13 +320,14 @@ impl OAuthHttpTransport for ReqwestOAuthTransport {
/// 官方 Grok Responses HTTP SSE transport。
pub struct ReqwestGrokInferenceTransport {
clients: Mutex<BoundInferenceClients>,
clients: EgressCache<GrokSessionBinding, Client>,
unbound_client: Mutex<Option<Client>>,
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
}
impl ReqwestGrokInferenceTransport {
/// 单个进程缓存的账号隔离推理连接池上限。
pub const MAX_CACHED_ACCOUNT_CLIENTS: usize = 64;
pub const MAX_CACHED_ACCOUNT_CLIENTS: usize = MAX_CACHED_EGRESS_STATES;
/// 构建只允许官方 CLI proxy 的生产 transport。
///
@@ -321,47 +337,44 @@ impl ReqwestGrokInferenceTransport {
pub fn new(
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
) -> Result<Self, GrokReqwestTransportBuildError> {
let unbound_client = endpoint_policy.build_inference_client(None)?;
let unbound_client = endpoint_policy.build_inference_client(None, None)?;
Ok(Self {
clients: Mutex::new(BoundInferenceClients::new(unbound_client)),
clients: EgressCache::new(),
unbound_client: Mutex::new(Some(unbound_client)),
endpoint_policy,
})
}
fn client_for(
async fn client_for(
&self,
binding: &GrokSessionBinding,
) -> Result<(Client, GrokInferenceClientCacheStatus), GrokInferenceTransportError> {
let unbound_client = {
let mut clients = self
.clients
.lock()
.map_err(|_| inference_client_pool_unavailable())?;
if let Some(client) = clients.get(binding) {
return Ok((client, GrokInferenceClientCacheStatus::Hit));
}
if binding.outbound_proxy().is_none() {
clients.take_unbound()
} else {
None
}
};
let client = match unbound_client {
Some(client) => client,
None => self
.endpoint_policy
.build_inference_client(None)
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?,
};
let client = account_proxy_client(&client, binding.outbound_proxy(), None)
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?;
let client = self
let entry = self
.clients
.lock()
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?
.insert_if_absent(binding, client);
Ok((client, GrokInferenceClientCacheStatus::Miss))
.entry(binding.clone())
.ok_or_else(inference_client_pool_unavailable_for_cache_miss)?;
let status = if entry.initialized() {
GrokInferenceClientCacheStatus::Hit
} else {
GrokInferenceClientCacheStatus::Miss
};
let client = entry
.get_or_try_init(|| async {
if binding.outbound_proxy().is_none()
&& let Some(client) = self
.unbound_client
.lock()
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)?
.take()
{
return Ok(client);
}
self.endpoint_policy
.build_inference_client(None, binding.outbound_proxy())
})
.await
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?;
Ok((client.clone(), status))
}
}
@@ -396,7 +409,7 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
.map(|header| (header.name(), header.value().expose().as_bytes())),
);
trace.capture("upstream.request.body", request.body());
let (client, client_cache_status) = self.client_for(request.binding())?;
let (client, client_cache_status) = self.client_for(request.binding()).await?;
let mut builder = client
.post(request.endpoint().clone())
.body(request.body().to_vec());
@@ -475,62 +488,6 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
}
}
struct BoundInferenceClients {
by_binding: HashMap<GrokSessionBinding, Client>,
least_recently_used: VecDeque<GrokSessionBinding>,
unbound_client: Option<Client>,
}
impl BoundInferenceClients {
fn new(unbound_client: Client) -> Self {
Self {
by_binding: HashMap::with_capacity(
ReqwestGrokInferenceTransport::MAX_CACHED_ACCOUNT_CLIENTS,
),
least_recently_used: VecDeque::with_capacity(
ReqwestGrokInferenceTransport::MAX_CACHED_ACCOUNT_CLIENTS,
),
unbound_client: Some(unbound_client),
}
}
fn get(&mut self, binding: &GrokSessionBinding) -> Option<Client> {
if let Some(client) = self.by_binding.get(binding).cloned() {
self.record_use(binding);
return Some(client);
}
None
}
fn take_unbound(&mut self) -> Option<Client> {
self.unbound_client.take()
}
fn insert_if_absent(&mut self, binding: &GrokSessionBinding, client: Client) -> Client {
if let Some(existing) = self.get(binding) {
return existing;
}
self.insert(binding.clone(), client.clone());
client
}
fn insert(&mut self, binding: GrokSessionBinding, client: Client) {
if self.by_binding.len() == ReqwestGrokInferenceTransport::MAX_CACHED_ACCOUNT_CLIENTS
&& let Some(expired_binding) = self.least_recently_used.pop_front()
{
self.by_binding.remove(&expired_binding);
}
self.least_recently_used.push_back(binding.clone());
self.by_binding.insert(binding, client);
}
fn record_use(&mut self, binding: &GrokSessionBinding) {
self.least_recently_used
.retain(|candidate| candidate != binding);
self.least_recently_used.push_back(binding.clone());
}
}
fn inference_client_pool_unavailable() -> GrokInferenceTransportError {
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::Unavailable,
@@ -563,7 +520,7 @@ fn inference_transport_metrics(
/// 官方 Grok CLI proxy 模型目录 GET transport。
pub struct ReqwestGrokModelCatalogTransport {
client: Client,
clients: EgressCache<Option<OutboundProxy>, Client>,
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
}
@@ -576,9 +533,9 @@ impl ReqwestGrokModelCatalogTransport {
pub fn new(
endpoint_policy: Arc<dyn GrokEndpointPolicy>,
) -> Result<Self, GrokReqwestTransportBuildError> {
let client = endpoint_policy.build_inference_client(Some(OAUTH_REQUEST_TIMEOUT))?;
let client = endpoint_policy.build_inference_client(Some(OAUTH_REQUEST_TIMEOUT), None)?;
Ok(Self {
client,
clients: EgressCache::with_entry(None, client),
endpoint_policy,
})
}
@@ -596,7 +553,6 @@ impl fmt::Debug for ReqwestGrokModelCatalogTransport {
impl GrokModelCatalogTransport for ReqwestGrokModelCatalogTransport {
fn execute(&self, request: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> {
let client = self.client.clone();
let endpoint_policy = self.endpoint_policy.clone();
Box::pin(async move {
if !endpoint_policy.validate_model_catalog(request.endpoint()) {
@@ -604,14 +560,27 @@ impl GrokModelCatalogTransport for ReqwestGrokModelCatalogTransport {
GrokModelCatalogTransportErrorKind::Protocol,
));
}
let client = account_proxy_client(
&client,
request.outbound_proxy.as_ref(),
Some(OAUTH_REQUEST_TIMEOUT),
)
.map_err(|_| {
GrokModelCatalogTransportError::new(GrokModelCatalogTransportErrorKind::Protocol)
})?;
let entry = self
.clients
.entry(request.outbound_proxy.clone())
.ok_or_else(|| {
GrokModelCatalogTransportError::new(
GrokModelCatalogTransportErrorKind::Transport,
)
})?;
let client = entry
.get_or_try_init(|| async {
endpoint_policy.build_inference_client(
Some(OAUTH_REQUEST_TIMEOUT),
request.outbound_proxy.as_ref(),
)
})
.await
.map_err(|_| {
GrokModelCatalogTransportError::new(
GrokModelCatalogTransportErrorKind::Protocol,
)
})?;
let mut builder = client.get(request.endpoint().clone());
for header in request.headers() {
builder = builder.header(header.name(), header.value().expose());
@@ -655,7 +624,6 @@ impl GrokModelCatalogTransport for ReqwestGrokModelCatalogTransport {
impl GrokBillingTransport for ReqwestGrokModelCatalogTransport {
fn execute(&self, request: GrokBillingRequest) -> GrokBillingTransportFuture<'_> {
let client = self.client.clone();
let endpoint_policy = self.endpoint_policy.clone();
Box::pin(async move {
let endpoint = endpoint_policy
@@ -663,12 +631,23 @@ impl GrokBillingTransport for ReqwestGrokModelCatalogTransport {
.ok_or_else(|| {
GrokBillingTransportError::new(GrokBillingTransportErrorKind::Protocol)
})?;
let client = account_proxy_client(
&client,
request.outbound_proxy.as_ref(),
Some(OAUTH_REQUEST_TIMEOUT),
)
.map_err(|_| GrokBillingTransportError::new(GrokBillingTransportErrorKind::Protocol))?;
let entry = self
.clients
.entry(request.outbound_proxy.clone())
.ok_or_else(|| {
GrokBillingTransportError::new(GrokBillingTransportErrorKind::Transport)
})?;
let client = entry
.get_or_try_init(|| async {
endpoint_policy.build_inference_client(
Some(OAUTH_REQUEST_TIMEOUT),
request.outbound_proxy.as_ref(),
)
})
.await
.map_err(|_| {
GrokBillingTransportError::new(GrokBillingTransportErrorKind::Protocol)
})?;
let mut builder = client.get(endpoint);
for header in request.headers() {
builder = builder.header(header.name(), header.value().expose());
@@ -702,6 +681,7 @@ impl GrokBillingTransport for ReqwestGrokModelCatalogTransport {
fn build_official_client(
dns_policy: GrokDnsResolutionPolicy,
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
let mut builder = Client::builder()
.redirect(Policy::none())
@@ -714,42 +694,23 @@ fn build_official_client(
.http2_keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT)
.http2_keep_alive_while_idle(true)
.tcp_nodelay(true)
.https_only(true)
.dns_resolver(Arc::new(StrictDnsResolver::new(dns_policy)?));
if let Some(timeout) = timeout {
builder = builder.timeout(timeout);
}
builder
.build()
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)
}
pub(crate) fn account_proxy_client(
base: &Client,
proxy: Option<&gateway_core::account::OutboundProxy>,
timeout: Option<Duration>,
) -> Result<Client, GrokReqwestTransportBuildError> {
let Some(proxy) = proxy else {
return Ok(base.clone());
};
let invalid = || GrokReqwestTransportBuildError::ClientInitialization;
let mut builder = Client::builder()
.no_proxy()
.redirect(Policy::none())
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| invalid())?)
.connect_timeout(CONNECT_TIMEOUT)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
.http2_adaptive_window(true)
.http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL)
.http2_keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT)
.http2_keep_alive_while_idle(true)
.tcp_nodelay(true)
.https_only(true);
if let Some(proxy) = proxy {
// 保留 reqwest 的代理解析语义(socks5 本地解析、socks5h 远端解析);
// 只允许官方 host 的直连 resolver 不能用于解析代理端点。
builder = builder.proxy(
reqwest::Proxy::all(proxy.expose_url())
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)?,
);
} else {
builder = builder.dns_resolver(Arc::new(StrictDnsResolver::new(dns_policy)?));
}
if let Some(timeout) = timeout {
builder = builder.timeout(timeout);
}
builder.build().map_err(|_| invalid())
builder
.build()
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)
}
fn valid_official_url(url: &Url, host: &str, path: Option<&str>) -> bool {
+96 -3
View File
@@ -38,16 +38,109 @@ use gateway_core::task::{
WorkerContribution, WorkerCycleContext, WorkerKind, WorkerRunnable, WorkerTaskError,
};
use provider_xai::{
DiscoveryDocument, GrokOAuthConfig, PendingAuthorization, RedirectUriAllowlist,
DiscoveryDocument, GrokAccountSessionSelector, GrokCredentialFailure, GrokCredentialRepository,
GrokOAuthConfig, GrokSessionBinding, GrokSessionSelector, OfficialGrokEndpointPolicy,
PendingAuthorization, RedirectUriAllowlist, ReqwestGrokModelCatalogTransport, SecretValue,
SelectedGrokSession,
};
use serde_json::{Map, Value, json};
use sha2::{Digest as _, Sha256};
use crate::support::{
MemoryProviderAccountStore, TestSessionAffinity, TestSessionExclusions, create_input,
seed_input, xai_config,
MemoryCooldownPort, MemoryGrokCatalogCache, MemoryProviderAccountStore, TestSessionAffinity,
TestSessionExclusions, account_id, create_input, seed_input, xai_config,
};
#[tokio::test]
async fn account_unavailable_clears_real_selector_cooldowns_only_for_deleted_account() {
let store = MemoryProviderAccountStore::shared();
let cooldowns = Arc::new(MemoryCooldownPort::default());
let ports = ProviderStorePorts::new(
store.clone(),
Arc::new(TestLeases),
Arc::new(TestSessionAffinity),
Arc::new(TestSessionExclusions),
Arc::new(TestCatalogCache::default()),
Arc::new(TestArtifactProfiles),
Arc::new(TestCredentialState),
cooldowns.clone(),
Arc::new(TestRuntimePolicy),
Arc::new(TestOAuthPending::default()),
);
let repository = GrokCredentialRepository::new(store.clone());
let selector = GrokAccountSessionSelector::new(
ProviderKind::new("xai").unwrap(),
repository.clone(),
MemoryGrokCatalogCache::shared(),
Arc::new(crate::support::grok_quota_service(
repository,
Arc::new(
ReqwestGrokModelCatalogTransport::new(Arc::new(OfficialGrokEndpointPolicy))
.unwrap(),
),
)),
ports.leases(),
cooldowns.clone(),
ports.account_feedback(),
);
let bundle = provider_xai::initialize(xai_config(), ports).await.unwrap();
let models = ["grok-4.5", "grok-4.6"].map(|id| UpstreamModelId::new(id).unwrap());
for suffix in ["deleted-cooldown", "retained-cooldown"] {
let input = create_input(suffix, &format!("subject-{suffix}"));
seed_input(&store, &input).await.unwrap();
let session = SelectedGrokSession::new(
input.account_id,
CredentialRevision::new(1).unwrap(),
input.secret.access_token,
SecretValue::new(input.account.subject),
None,
GrokSessionBinding::new(suffix).unwrap(),
(),
)
.unwrap();
// cooldown 必须来自生产 selector 对真实反馈类型的处理,不能由 mock 手工伪造。
selector
.record_failure(
&session,
GrokCredentialFailure::RateLimited {
retry_after: Some(Duration::from_secs(60)),
},
)
.await;
for model in &models {
selector
.record_failure(
&session,
GrokCredentialFailure::ModelQuotaExhausted {
upstream_model: model.clone(),
retry_after: None,
},
)
.await;
assert!(
cooldowns
.scoped_cooldown(
session.account_id(),
&ProviderCooldownScope::upstream_model(model.clone())
)
.is_some()
);
}
assert!(cooldowns.cooldown(session.account_id()).is_some());
}
let deleted = account_id("deleted-cooldown");
store.delete_account(&deleted).await.unwrap();
bundle.admin_provider().account_unavailable(&deleted).await;
assert!(cooldowns.cooldown(&deleted).is_none());
let retained = account_id("retained-cooldown");
assert!(cooldowns.cooldown(&retained).is_some());
for model in models {
let scope = ProviderCooldownScope::upstream_model(model);
assert!(cooldowns.scoped_cooldown(&deleted, &scope).is_none());
assert!(cooldowns.scoped_cooldown(&retained, &scope).is_some());
}
}
#[tokio::test]
async fn xai_bundle_exposes_core_admin_and_drains_worker_contributions_once() {
let mut bundle = provider_xai::initialize(xai_config(), provider_ports())
@@ -19,7 +19,7 @@ use provider_xai::{
GrokCredentialCatalogSeed, GrokCredentialRepository, GrokModelCatalogRequest,
GrokModelCatalogTransport, GrokModelCatalogTransportError, GrokModelCatalogTransportErrorKind,
GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse, GrokPlanCatalog,
GrokQuotaError, SecretValue,
GrokQuotaError,
};
use crate::support::{
@@ -643,26 +643,6 @@ fn seed_rejects_duplicates_and_supports_exact_membership() {
assert!(!seed.permits("grok-4"));
}
#[tokio::test]
async fn fetch_seed_rejects_non_header_safe_identity() {
let (_, repository) = repository_with_accounts(&[]).await;
let service = crate::support::grok_catalog_service(
repository,
QueueCatalogTransport::from_bodies([OFFICIAL_FIXTURE.to_vec()]),
MemoryGrokCatalogCache::shared(),
);
assert!(matches!(
service
.fetch_seed(
SecretValue::new("access"),
SecretValue::new("非-ascii"),
None,
)
.await,
Err(GrokCredentialCatalogError::InvalidCredentialData)
));
}
#[tokio::test]
async fn quota_refresh_persists_dynamic_provider_document_and_projects_known_fields() {
let (store, repository) = repository_with_accounts(&[("quota", "subject-quota")]).await;
@@ -23,6 +23,135 @@ use crate::support::loopback_endpoint_policy;
const CACHE_TTL: Duration = Duration::from_secs(60 * 60);
#[tokio::test]
async fn proxy_jwks_cache_evicts_idle_exits_at_the_fixed_bound() {
let origin = MockServer::start().await;
let exit = MockServer::start().await;
let endpoint = endpoints(&origin);
let capacity = provider_xai::ReqwestGrokInferenceTransport::MAX_CACHED_ACCOUNT_CLIENTS;
let key = TestKey::new(36, "bounded-proxy-key");
mount_jwks(&exit, key.jwks(), (capacity + 2) as u64).await;
let verifier = verifier(&endpoint.origin, CACHE_TTL);
let nonce = SecretValue::new("bounded-proxy-nonce");
let algorithms = vec!["ES256".to_owned()];
let access = SecretValue::new("access-token");
let token = SecretValue::new(key.sign(&valid_claims(&nonce), Algorithm::ES256));
let proxies = (0..=capacity)
.map(|index| {
let mut url = Url::parse(&exit.uri()).unwrap();
url.set_username(&format!("synthetic-exit-{index}"))
.unwrap();
gateway_core::account::OutboundProxy::parse(url.as_str()).unwrap()
})
.collect::<Vec<_>>();
for index in (0..=capacity).chain([capacity, 0]) {
verifier
.verify(
auth_context(&endpoint, &algorithms, &nonce)
.with_outbound_proxy(Some(&proxies[index])),
TokenCandidate::new(&access, Some(&token), None),
)
.await
.unwrap();
}
// 65 个出口各取一次,最近出口命中,最旧出口被淘汰后重新获取。
assert_eq!(exit.received_requests().await.unwrap().len(), capacity + 2);
assert!(origin.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn proxy_jwks_cache_single_flights_and_isolates_each_exit() {
let origin = MockServer::start().await;
let exit_a = MockServer::start().await;
let exit_b = MockServer::start().await;
let endpoint = endpoints(&origin);
let proxy_a = gateway_core::account::OutboundProxy::parse(&exit_a.uri()).unwrap();
let proxy_b = gateway_core::account::OutboundProxy::parse(&exit_b.uri()).unwrap();
let nonce = SecretValue::new("proxy-bound-nonce");
let algorithms = vec!["ES256".to_owned()];
let access = SecretValue::new("access-token");
let verifier = verifier(&endpoint.origin, CACHE_TTL);
// 同一 kid 配不同密钥:复用别的出口缓存会直接使验签失败。
for (server, proxy, seed) in [
(&exit_a, Some(&proxy_a), 31),
(&exit_b, Some(&proxy_b), 32),
(&origin, None, 33),
] {
let key = TestKey::new(seed, "shared-kid");
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(key.jwks())
.set_delay(Duration::from_millis(25)),
)
.expect(1)
.mount(server)
.await;
let token = SecretValue::new(key.sign(&valid_claims(&nonce), Algorithm::ES256));
let verify = || {
verifier.verify(
auth_context(&endpoint, &algorithms, &nonce).with_outbound_proxy(proxy),
TokenCandidate::new(&access, Some(&token), None),
)
};
let (first, second) = tokio::join!(verify(), verify());
assert_eq!(first.unwrap().subject(), "user-123");
assert_eq!(second.unwrap().subject(), "user-123");
assert!(verify().await.is_ok(), "同出口后续验证必须命中缓存");
}
}
#[tokio::test]
async fn proxy_jwks_rotation_and_expired_cache_remain_fail_closed() {
for ttl in [CACHE_TTL, Duration::ZERO] {
let origin = MockServer::start().await;
let exit = MockServer::start().await;
let endpoint = endpoints(&origin);
let proxy = gateway_core::account::OutboundProxy::parse(&exit.uri()).unwrap();
let verifier = verifier(&endpoint.origin, ttl);
let old = TestKey::new(34, "old-proxy-key");
let new = TestKey::new(35, "rotated-proxy-key");
mount_jwks_once_then(&exit, old.jwks(), new.jwks()).await;
let nonce = SecretValue::new("rotation-proxy-nonce");
let algorithms = vec!["ES256".to_owned()];
let access = SecretValue::new("access-token");
for key in [&old, &new] {
let token = SecretValue::new(key.sign(&valid_claims(&nonce), Algorithm::ES256));
assert!(
verifier
.verify(
auth_context(&endpoint, &algorithms, &nonce)
.with_outbound_proxy(Some(&proxy)),
TokenCandidate::new(&access, Some(&token), None),
)
.await
.is_ok()
);
}
exit.verify().await;
exit.reset().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&exit)
.await;
// TTL 到期或未知 kid 均不能在获取失败后借用旧缓存。
let rejected_key = if ttl.is_zero() { &new } else { &old };
let token = SecretValue::new(rejected_key.sign(&valid_claims(&nonce), Algorithm::ES256));
assert!(matches!(
verifier
.verify(
auth_context(&endpoint, &algorithms, &nonce).with_outbound_proxy(Some(&proxy)),
TokenCandidate::new(&access, Some(&token), None),
)
.await,
Err(VerificationFailure::Unavailable)
));
assert!(origin.received_requests().await.unwrap().is_empty());
}
}
#[tokio::test]
async fn authorization_code_should_validate_es256_and_cache_jwks() {
let server = MockServer::start().await;
@@ -7,7 +7,7 @@ use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use chrono::Utc;
use gateway_core::account::{
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState, OutboundProxy,
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
};
use gateway_core::provider_ports::{
@@ -18,20 +18,170 @@ use gateway_core::provider_ports::{
use provider_xai::{
GrokCredentialCatalogCache, GrokCredentialRecovery, GrokCredentialRecoveryOutcome,
GrokCredentialRefreshError, GrokCredentialRefreshOutcome, GrokCredentialRefreshService,
GrokCredentialRefresher, GrokCredentialRepository, GrokModelCatalogRequest,
GrokCredentialRefresher, GrokCredentialRepository, GrokEndpointPolicy, GrokModelCatalogRequest,
GrokModelCatalogTransport, GrokModelCatalogTransportFuture, GrokModelCatalogTransportResponse,
GrokRefreshFailure, GrokRefreshTokens, SecretValue,
GrokRefreshFailure, GrokRefreshTokens, GrokReqwestTransportBuildError,
OfficialGrokEndpointPolicy, ReqwestGrokModelCatalogTransport, SecretValue,
};
use crate::support::{
MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input, credential_object,
runtime_policy, seed_input,
rejecting_account_proxy, runtime_policy, seed_input,
};
const OFFICIAL_FIXTURE: &[u8] =
include_bytes!("../transport/catalog/fixtures/official_grok_models_snapshot.json");
const OAUTH_BACKOFF_ATTEMPTS: u32 = 5;
#[tokio::test]
async fn refreshed_catalog_uses_committed_account_exit_including_proxy_changes() {
// 0: 保留 A;1: 刷新在途时 A→B;2: 刷新在途时 A→直连。
for selected_exit in 0..3 {
let (proxy_a, capture_a) = rejecting_account_proxy().await;
let (proxy_b, capture_b) = rejecting_account_proxy().await;
let (direct_sink, capture_direct) = rejecting_account_proxy().await;
let current_proxy = [Some(proxy_a.clone()), Some(proxy_b), None][selected_exit].clone();
let store = MemoryProviderAccountStore::shared();
let input = due_input("postcommit-egress");
seed_input(&store, &input).await.unwrap();
store.set_outbound_proxy(&input.account_id, Some(proxy_a.clone()));
let repository = GrokCredentialRepository::new(store.clone());
let refresher = Arc::new(SwitchingProxyRefresher {
store: Arc::clone(&store),
account_id: input.account_id.clone(),
initial_proxy: proxy_a,
current_proxy: current_proxy.clone(),
});
let catalog = Arc::new(crate::support::grok_catalog_service(
repository.clone(),
Arc::new(
ReqwestGrokModelCatalogTransport::new(Arc::new(CatalogExitPolicy { direct_sink }))
.unwrap(),
),
MemoryGrokCatalogCache::shared(),
));
let service = GrokCredentialRefreshService::new(
repository,
refresher,
catalog,
Arc::new(TestRefreshLeases {
available: true,
calls: AtomicUsize::new(0),
}),
Arc::new(CountingCredentialState::default()),
runtime_policy(),
);
let outcomes = tokio::time::timeout(Duration::from_secs(5), service.refresh_due())
.await
.unwrap()
.unwrap();
assert!(matches!(outcomes.as_slice(), [
GrokCredentialRefreshOutcome::Refreshed { credential_revision, .. }
] if credential_revision.get() == 2));
let committed = store.account(&input.account_id).unwrap();
assert_eq!(committed.outbound_proxy(), current_proxy.as_ref());
assert_eq!(committed.revision().get(), 2);
for (index, mut capture) in [capture_a, capture_b, capture_direct]
.into_iter()
.enumerate()
{
if index == selected_exit {
let connect = tokio::time::timeout(Duration::from_secs(5), &mut capture)
.await
.unwrap()
.unwrap();
assert!(connect.starts_with("CONNECT cli-chat-proxy.grok.com:443 HTTP/1.1"));
} else {
assert!(!capture.is_finished(), "目录请求不得到达旧出口或误走直连");
capture.abort();
}
}
}
}
struct SwitchingProxyRefresher {
store: Arc<MemoryProviderAccountStore>,
account_id: ProviderAccountId,
initial_proxy: OutboundProxy,
current_proxy: Option<OutboundProxy>,
}
#[async_trait]
impl GrokCredentialRefresher for SwitchingProxyRefresher {
async fn prepare_cycle(&self) -> Result<(), GrokRefreshFailure> {
Ok(())
}
async fn refresh(&self, _: &SecretValue) -> Result<GrokRefreshTokens, GrokRefreshFailure> {
panic!("refresh service must forward the selected account proxy")
}
async fn refresh_with_proxy(
&self,
_: &SecretValue,
proxy: Option<&OutboundProxy>,
) -> Result<GrokRefreshTokens, GrokRefreshFailure> {
assert_eq!(proxy, Some(&self.initial_proxy));
// 精确合成网络刷新已开始、credential CAS 尚未提交的出口修改。
self.store
.set_outbound_proxy(&self.account_id, self.current_proxy.clone());
assert_eq!(
self.store
.account(&self.account_id)
.unwrap()
.revision()
.get(),
1
);
Ok(success_tokens(Some("new-refresh")))
}
}
#[derive(Debug)]
struct CatalogExitPolicy {
direct_sink: OutboundProxy,
}
impl GrokEndpointPolicy for CatalogExitPolicy {
fn build_oauth_client(
&self,
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<reqwest::Client, GrokReqwestTransportBuildError> {
self.build_inference_client(timeout, proxy)
}
fn build_inference_client(
&self,
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<reqwest::Client, GrokReqwestTransportBuildError> {
// “直连”也终止于本地 sink,旧实现漏代理时不会触达真实上游。
// 显式代理分支仍使用生产构造器及官方 HTTPS/CONNECT 目标。
OfficialGrokEndpointPolicy
.build_inference_client(timeout, Some(proxy.unwrap_or(&self.direct_sink)))
}
fn validate_oauth(&self, url: &url::Url) -> bool {
OfficialGrokEndpointPolicy.validate_oauth(url)
}
fn validate_inference(&self, url: &url::Url) -> bool {
OfficialGrokEndpointPolicy.validate_inference(url)
}
fn validate_model_catalog(&self, url: &url::Url) -> bool {
OfficialGrokEndpointPolicy.validate_model_catalog(url)
}
fn route_billing(&self, url: &url::Url) -> Option<url::Url> {
OfficialGrokEndpointPolicy.route_billing(url)
}
fn validate_jwks(&self, url: &url::Url) -> bool {
OfficialGrokEndpointPolicy.validate_jwks(url)
}
fn validate_userinfo(&self, url: &url::Url) -> bool {
OfficialGrokEndpointPolicy.validate_userinfo(url)
}
}
struct StaticCatalogTransport;
impl GrokModelCatalogTransport for StaticCatalogTransport {
@@ -12,9 +12,8 @@ use gateway_core::account::{
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
ProviderCooldownPort, ProviderCooldownScope, ProviderLeaseAcquisition, ProviderLeasePort,
ProviderLeaseRequest, ProviderSchedulingLeaseRequest, ProviderSchedulingState,
ProviderStoreError,
ProviderCooldownScope, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
ProviderSchedulingLeaseRequest, ProviderSchedulingState, ProviderStoreError,
};
use gateway_core::routing::{
ClientRoutingScope, FrozenAccountScope, RuntimeAccount, RuntimeAccountDirectory,
@@ -1032,66 +1031,6 @@ async fn quota_reset_strategy_uses_provider_reported_earliest_reset() {
assert_eq!(session.account_id(), &account_id("zzz-earlier-reset"));
}
#[tokio::test]
async fn deleting_account_clears_all_account_and_model_scoped_cooldowns() {
// 删除账号后其 account/model scoped cooldown key 全部清除。
let fixture = SelectorFixture::new(&["clear-cooldowns", "available"]).await;
let session = fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await
.expect("session");
let selected = session.account_id().clone();
let model = UpstreamModelId::new("grok-4.5").expect("model");
// 写 account cooldown + model scoped cooldown。
fixture
.selector
.record_failure(
&session,
GrokCredentialFailure::RateLimited {
retry_after: Some(Duration::from_secs(60)),
},
)
.await;
fixture
.selector
.record_failure(
&session,
GrokCredentialFailure::ModelQuotaExhausted {
upstream_model: model.clone(),
retry_after: Some(Duration::from_secs(3600)),
},
)
.await;
assert!(fixture.cooldowns.cooldown(&selected).is_some());
assert!(
fixture
.cooldowns
.scoped_cooldown(
&selected,
&ProviderCooldownScope::upstream_model(model.clone())
)
.is_some()
);
// 删除账号(clear_all 清全部)。
fixture
.cooldowns
.clear_all(&selected)
.await
.expect("clear all cooldowns");
assert!(fixture.cooldowns.cooldown(&selected).is_none());
assert!(
fixture
.cooldowns
.scoped_cooldown(
&selected,
&ProviderCooldownScope::upstream_model(model.clone())
)
.is_none()
);
}
#[tokio::test]
async fn bare_402_cooldown_expires_and_account_recovers_without_persisted_exhaustion() {
// bare 402 只写短期 account cooldown,不持久化
+54 -3
View File
@@ -115,7 +115,10 @@ impl LoopbackGrokEndpointPolicy {
&& url.fragment().is_none()
}
fn build_client(timeout: Option<Duration>) -> Result<Client, GrokReqwestTransportBuildError> {
fn build_client(
timeout: Option<Duration>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
let mut builder = Client::builder()
.redirect(Policy::none())
.no_proxy()
@@ -124,6 +127,12 @@ impl LoopbackGrokEndpointPolicy {
if let Some(timeout) = timeout {
builder = builder.timeout(timeout);
}
if let Some(proxy) = proxy {
builder = builder.proxy(
reqwest::Proxy::all(proxy.expose_url())
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)?,
);
}
builder
.build()
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)
@@ -134,15 +143,17 @@ impl GrokEndpointPolicy for LoopbackGrokEndpointPolicy {
fn build_oauth_client(
&self,
timeout: Option<Duration>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
Self::build_client(timeout)
Self::build_client(timeout, proxy)
}
fn build_inference_client(
&self,
timeout: Option<Duration>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
Self::build_client(timeout)
Self::build_client(timeout, proxy)
}
fn validate_oauth(&self, url: &Url) -> bool {
@@ -193,6 +204,35 @@ pub fn loopback_endpoint_policy(origin: &Url) -> Arc<dyn GrokEndpointPolicy> {
Arc::new(LoopbackGrokEndpointPolicy::for_origin(origin))
}
pub async fn rejecting_account_proxy() -> (
gateway_core::account::OutboundProxy,
tokio::task::JoinHandle<String>,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = gateway_core::account::OutboundProxy::parse(&format!(
"http://user:pass@{}",
listener.local_addr().unwrap()
))
.unwrap();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut header = Vec::new();
while !header.ends_with(b"\r\n\r\n") {
header.push(stream.read_u8().await.unwrap());
assert!(header.len() < 8192);
}
stream
.write_all(
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
String::from_utf8(header).unwrap()
});
(proxy, task)
}
#[derive(Clone)]
struct StoredAccount {
account: ProviderAccount,
@@ -232,6 +272,16 @@ impl MemoryProviderAccountStore {
.with_scheduling(concurrency_limit, weight);
}
pub fn set_outbound_proxy(
&self,
id: &ProviderAccountId,
proxy: Option<gateway_core::account::OutboundProxy>,
) {
let mut accounts = lock(&self.accounts);
let stored = accounts.get_mut(id).expect("seeded account");
stored.account = stored.account.clone().with_outbound_proxy(proxy);
}
pub fn last_error_message(&self, id: &ProviderAccountId) -> Option<String> {
lock(&self.accounts)
.get(id)
@@ -619,6 +669,7 @@ fn rebuild_account(previous: &ProviderAccount, replacement: AccountReplacement)
)
.with_scheduling(previous.concurrency_limit(), previous.weight())
.with_refresh_schedule(replacement.has_refresh_token, replacement.next_refresh_at)
.with_outbound_proxy(previous.outbound_proxy().cloned())
}
#[derive(Default)]
@@ -640,6 +640,19 @@ async fn client_should_enforce_hard_limit_for_injected_transport_too() {
));
}
#[test]
fn session_rejects_non_header_safe_identity() {
assert!(matches!(
GrokModelCatalogSession::new(
SecretValue::new("oauth-access"),
SecretValue::new("非-ascii"),
None,
crate::support::xai_wire_profile(),
),
Err(provider_xai::GrokModelCatalogSessionError::InvalidHeaderData)
));
}
#[test]
fn session_debug_should_redact_oauth_and_identity_values() {
let debug = format!("{:?}", session(Some("person@example.com")));
@@ -0,0 +1,125 @@
//! 通过生产辅助请求 transport 观察出口 cache 的真实连接生命周期。
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use gateway_core::account::OutboundProxy;
use provider_xai::{
GrokBillingClient, GrokModelCatalogSession, ReqwestGrokInferenceTransport,
ReqwestGrokModelCatalogTransport, SecretValue,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::task::{JoinHandle, JoinSet};
use url::Url;
use crate::support::{loopback_endpoint_policy, xai_wire_profile};
#[tokio::test]
async fn auxiliary_pool_reuses_connections_and_evicts_the_least_recent_exit() {
let exit = KeepAliveExit::start().await;
let transport = Arc::new(
ReqwestGrokModelCatalogTransport::new(loopback_endpoint_policy(&exit.origin)).unwrap(),
);
let client = GrokBillingClient::new(transport);
let capacity = ReqwestGrokInferenceTransport::MAX_CACHED_ACCOUNT_CLIENTS;
let proxies = (0..=capacity)
.map(|index| {
let mut url = exit.origin.clone();
url.set_username(&format!("synthetic-exit-{index}"))
.unwrap();
OutboundProxy::parse(url.as_str()).unwrap()
})
.collect::<Vec<_>>();
for proxy in &proxies {
fetch_billing(&client, proxy).await;
}
assert_eq!(exit.connections.load(Ordering::SeqCst), capacity + 1);
fetch_billing(&client, proxies.last().unwrap()).await;
assert_eq!(
exit.connections.load(Ordering::SeqCst),
capacity + 1,
"命中出口应复用实际 TCP 连接,而不只是复用构造计数"
);
fetch_billing(&client, &proxies[0]).await;
assert_eq!(
exit.connections.load(Ordering::SeqCst),
capacity + 2,
"最旧出口已淘汰,重新请求必须建立新连接"
);
}
async fn fetch_billing(client: &GrokBillingClient, proxy: &OutboundProxy) {
let session = GrokModelCatalogSession::new(
SecretValue::new("synthetic-access"),
SecretValue::new("synthetic-user"),
None,
xai_wire_profile(),
)
.unwrap()
.with_outbound_proxy(Some(proxy.clone()));
tokio::time::timeout(Duration::from_secs(5), client.fetch(&session))
.await
.unwrap()
.unwrap();
}
struct KeepAliveExit {
origin: Url,
connections: Arc<AtomicUsize>,
task: JoinHandle<()>,
}
impl KeepAliveExit {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
let connections = Arc::new(AtomicUsize::new(0));
let accepted = Arc::clone(&connections);
let task = tokio::spawn(async move {
let mut requests = JoinSet::new();
loop {
tokio::select! {
connection = listener.accept() => {
let (mut socket, _) = connection.unwrap();
accepted.fetch_add(1, Ordering::SeqCst);
requests.spawn(async move {
loop {
let mut header = Vec::new();
while !header.ends_with(b"\r\n\r\n") {
let Ok(byte) = socket.read_u8().await else { return; };
header.push(byte);
assert!(header.len() < 16 * 1024);
}
assert!(header.starts_with(b"GET "));
let body = r#"{"config":{"creditUsagePercent":25}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len(),
);
if socket.write_all(response.as_bytes()).await.is_err() {
return;
}
}
});
}
result = requests.join_next(), if !requests.is_empty() => {
result.unwrap().unwrap();
}
}
}
});
Self {
origin,
connections,
task,
}
}
}
impl Drop for KeepAliveExit {
fn drop(&mut self) {
self.task.abort();
}
}
@@ -2,6 +2,7 @@ mod canonical;
mod catalog;
mod compaction;
mod config;
mod egress;
mod headers;
mod network;
mod profile;
@@ -24,36 +24,7 @@ use provider_xai::{
ReqwestOAuthTransport, SecretValue,
};
use crate::support::loopback_endpoint_policy;
async fn rejecting_account_proxy() -> (
gateway_core::account::OutboundProxy,
tokio::task::JoinHandle<String>,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = gateway_core::account::OutboundProxy::parse(&format!(
"http://user:pass@{}",
listener.local_addr().unwrap()
))
.unwrap();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut header = Vec::new();
while !header.ends_with(b"\r\n\r\n") {
header.push(stream.read_u8().await.unwrap());
assert!(header.len() < 8192);
}
stream
.write_all(
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
String::from_utf8(header).unwrap()
});
(proxy, task)
}
use crate::support::{loopback_endpoint_policy, rejecting_account_proxy};
#[tokio::test]
async fn inference_and_oauth_connect_through_each_accounts_authenticated_proxy() {
@@ -234,6 +205,130 @@ async fn inference_transport_should_reuse_one_client_only_within_the_same_bindin
assert_eq!(client_builds.load(Ordering::SeqCst), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inference_proxy_client_is_built_once_for_concurrent_same_binding() {
let origin_server = MockServer::start().await;
let proxy_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(
ResponseTemplate::new(200).set_body_raw("data: [DONE]\n\n", "text/event-stream"),
)
.expect(2)
.mount(&proxy_server)
.await;
let origin = Url::parse(&origin_server.uri()).unwrap();
let proxy = gateway_core::account::OutboundProxy::parse(&proxy_server.uri()).unwrap();
let builds = Arc::new(AtomicUsize::new(0));
let transport = Arc::new(
ReqwestGrokInferenceTransport::new(Arc::new(CountingEndpointPolicy {
inner: loopback_endpoint_policy(&origin),
inference_client_builds: Arc::clone(&builds),
concurrency: Some(Arc::new(BuildConcurrency::new(Duration::from_millis(100)))),
}))
.unwrap(),
);
let start = Arc::new(tokio::sync::Barrier::new(2));
let tasks = (0..2)
.map(|_| {
let transport = Arc::clone(&transport);
let start = Arc::clone(&start);
let binding = GrokSessionBinding::new("same-proxy-account")
.unwrap()
.with_outbound_proxy(Some(proxy.clone()));
let request = GrokInferenceRequest::new(
origin.join("v1/responses").unwrap(),
Vec::new(),
b"{}".to_vec(),
binding,
);
tokio::spawn(async move {
start.wait().await;
transport
.execute(request)
.await
.unwrap()
.into_body()
.collect::<Vec<_>>()
.await
})
})
.collect::<Vec<_>>();
for task in tasks {
assert!(task.await.unwrap().iter().all(Result::is_ok));
}
// 启动时的直连 client + 一个代理 client;不再先构建并丢弃另一个直连 client。
assert_eq!(builds.load(Ordering::SeqCst), 2);
assert!(origin_server.received_requests().await.unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auxiliary_proxy_clients_single_flight_and_clearing_restores_direct() {
let origin_server = MockServer::start().await;
let exit_a = MockServer::start().await;
let exit_b = MockServer::start().await;
for (server, expected) in [(&origin_server, 1), (&exit_a, 3), (&exit_b, 1)] {
Mock::given(method("GET"))
.and(path("/v1/billing"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"config":{"creditUsagePercent":25}})),
)
.expect(expected)
.mount(server)
.await;
}
let origin = Url::parse(&origin_server.uri()).unwrap();
let builds = Arc::new(AtomicUsize::new(0));
let transport = Arc::new(
ReqwestGrokModelCatalogTransport::new(Arc::new(CountingEndpointPolicy {
inner: loopback_endpoint_policy(&origin),
inference_client_builds: Arc::clone(&builds),
concurrency: Some(Arc::new(BuildConcurrency::new(Duration::from_millis(100)))),
}))
.unwrap(),
);
let proxy_a = gateway_core::account::OutboundProxy::parse(&exit_a.uri()).unwrap();
let proxy_b = gateway_core::account::OutboundProxy::parse(&exit_b.uri()).unwrap();
let start = Arc::new(tokio::sync::Barrier::new(2));
let tasks = (0..2)
.map(|_| {
let client = GrokBillingClient::new(transport.clone());
let start = Arc::clone(&start);
let proxy = proxy_a.clone();
tokio::spawn(async move {
let session = GrokModelCatalogSession::new(
SecretValue::new("access-token"),
SecretValue::new("user-id"),
None,
crate::support::xai_wire_profile(),
)
.unwrap()
.with_outbound_proxy(Some(proxy));
start.wait().await;
client.fetch(&session).await.unwrap();
})
})
.collect::<Vec<_>>();
for task in tasks {
task.await.unwrap();
}
assert_eq!(builds.load(Ordering::SeqCst), 2, "同出口并发仅初始化一次");
let client = GrokBillingClient::new(transport);
for (proxy, expected_builds) in [(Some(proxy_a), 2), (Some(proxy_b), 3), (None, 3)] {
let session = GrokModelCatalogSession::new(
SecretValue::new("access-token"),
SecretValue::new("user-id"),
None,
crate::support::xai_wire_profile(),
)
.unwrap()
.with_outbound_proxy(proxy);
client.fetch(&session).await.unwrap();
assert_eq!(builds.load(Ordering::SeqCst), expected_builds);
}
}
#[tokio::test]
async fn inference_transport_should_evict_the_least_recent_binding_at_the_fixed_capacity() {
let server = MockServer::start().await;
@@ -1089,22 +1184,24 @@ impl GrokEndpointPolicy for CountingEndpointPolicy {
fn build_oauth_client(
&self,
timeout: Option<Duration>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
self.inner.build_oauth_client(timeout)
self.inner.build_oauth_client(timeout, proxy)
}
fn build_inference_client(
&self,
timeout: Option<Duration>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
self.inference_client_builds.fetch_add(1, Ordering::SeqCst);
let Some(concurrency) = &self.concurrency else {
return self.inner.build_inference_client(timeout);
return self.inner.build_inference_client(timeout, proxy);
};
let active = concurrency.active.fetch_add(1, Ordering::SeqCst) + 1;
concurrency.max_active.fetch_max(active, Ordering::SeqCst);
std::thread::sleep(concurrency.delay);
let result = self.inner.build_inference_client(timeout);
let result = self.inner.build_inference_client(timeout, proxy);
concurrency.active.fetch_sub(1, Ordering::SeqCst);
result
}
+5
View File
@@ -43,3 +43,8 @@ export CPR_TEST_REDIS_URL='redis://:<password>@127.0.0.1:6379'
测试自建随机 schema / key 前缀做隔离,但仍应使用专用开发或测试实例,不要连接生产库。
凭据从自己的部署配置或 CI Secret 中读取,不要粘贴未脱敏的 `docker inspect` 输出。
环境变量未设置导致的跳过不算数据库测试通过。
运行包含 `StoreBundle` 初始化的完整集成测试时,两条测试 URL 都须包含密码,且密码满足 Store
启动配置的 48 位十六进制要求;仅能连接数据库并不代表该初始化合同通过。专用服务使用对应测试密码,
并在测试进程中清除 `CPR_DATABASE_URL`、`CPR_REDIS_URL`、`CPR_DATABASE_PASSWORD` 和
`CPR_REDIS_PASSWORD`,避免启动配置被部署环境覆盖。
+4 -3
View File
@@ -397,9 +397,10 @@ OAuth start 使用:
`refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留
现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
不以 host/path 形式作为拒绝条件。
- 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警,
不回滚已提交的账号;重新授权和手工或后台 RT 刷新只更新 token,不隐式等同于手工额度刷新,也不更新
既有账号资料或 OAuth principal。
- 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待
观测完成才返回成功。观测失败只记录告警,不回滚已提交的账号;手工或后台 RT 刷新只更新 token,
不隐式等同于手工额度刷新,也不更新既有账号资料或 OAuth principal。xAI 导入与 OAuth complete
使用相同的提交后观察流程。
- OAuth pending flow 先取得带过期时间的独占 claim,只有账号事务提交成功后才消费。失败会释放 claim,
但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。
- `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token
+20 -4
View File
@@ -110,13 +110,17 @@ flowchart LR
| `upstream` | 跨 Engine、Event、Error 与 Provider 共用的 transport 名称、发送状态和不透明上游值 |
| `lifecycle` | 取消信号、连接注册与 drain 合同 |
| `engine` | attempt、发送/提交屏障、执行编排和持久化调用时序 |
| `routing` | 冻结路由事实、请求计划以及运行时快照的表示与编译 |
| `routing` | 冻结路由事实、请求计划、Provider 只读目录合同以及运行时快照的表示与编译 |
| `runtime` | 当前快照的发布、读取、revision 订阅与周期对账任务 |
`event` 通过 `validation` / `upstream` 使用基础值,不再依赖承载原始事件的执行错误;账号值对象和
选择策略通过 `identity` / `account::scope` 使用身份与范围,不依赖路由计划。`routing` 和 `error` 的
既有公开类型路径保留 re-export,定义与 Core 内部使用均归属上述 owner。架构测试约束这些叶子依赖。
Provider 模型能力、目录代次与 `ProviderCatalogPort` 由 `routing::catalog` 定义。快照编译和对账只消费
该只读合同,不反向依赖执行注册表;现有 `ProviderRegistry` 直接实现目录端口,仍只维护一份 Provider
注册集合。已知空目录与查询失败的未知目录保持不同语义,目录替身无需实现请求执行。
`engine::observation` 统一维护单次响应的用量、费用、时间和响应 ID,并负责重试前清理;协调器继续
独占发送、提交、重试和终结顺序。Provider 上报费用优先于本地估算,丢弃的 attempt 不得污染最终计量。
Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请求重试而清空已产生的费用或未知计费状态。
@@ -191,6 +195,9 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca
连接池按出口隔离。修改代理推进配置 revision,使后续请求使用新出口;已执行请求可沿原连接完成。
代理变更不推进 `credential_revision`,不会使进行中的令牌刷新因凭据版本冲突而丢失结果。
xAI 的推理连接池继续按账号绑定隔离;OAuth、目录/计费辅助请求在各自 transport 内复用有界出口
client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提交凭据后的目录预热重新读取当前账号
出口,不从 token-only 路径绕过代理;JWKS 过期或获取失败仍不使用 stale fallback。
代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。
连接配置改变时在同一事务内同步关联账号并清除旧测试结果,已绑定的代理不可删除。
@@ -282,6 +289,8 @@ Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限
换号和内部重试复用同一名额,Key 与账号并发上限同时生效;预算拒绝或启动失败释放名额。
完成、失败、取消和断连使用同一结算路径,在结束时以网关请求 ID 幂等累计已取得费用。
执行会话持有结算与准入释放的同一个清理 future;协议等待被取消后,后续驱动或宿主 detached finalizer
继续原清理,不重复结算。只有执行终态、结算与释放均已返回,协议层才可视为 finalized 并放弃清理责任。
缺少用量或价格、无法取得 USD 费用的尝试按零累计,不创建待核账记录,也不会阻断 Key;
内部重试中已经取得的费用仍须累计。发送状态只决定重放是否安全,不能据此推断费用。
请求日志继续保留真实错误、用量与费用来源,账本按零累计不表示上游实际免费。
@@ -300,8 +309,9 @@ HTTP validation
-> Admin use case
-> Provider prepare/verify when needed
-> PostgreSQL transaction + audit
-> invalidate affected Provider-derived facts when needed
-> publish committed runtime snapshot
-> notify Provider-derived caches
-> best-effort observations when needed
```
会改变路由快照或安全配置的 mutation 在同一 PostgreSQL 事务中提交业务事实、推进内部
@@ -312,6 +322,10 @@ HTTP validation
revision;credential 轮换只推进账号自己的 `credential_revision`。Redis 通知用于缩短收敛延迟,
PostgreSQL 周期对账才是正确性基础。
同一 publisher 的管理提交、订阅通知与周期对账共享编译到发布/暂停的临界区,避免旧结果覆盖新授权或
晚到的失败暂停新快照;请求读取不等待刷新锁。对账仍允许持久 revision 回退,不能只比较版本大小。
配置不可确认时暂停新请求;仅目录 generation 变化且编译失败时继续使用旧快照,留待下次对账。
## 8. 状态所有权
| 状态 | 唯一权威 | 说明 |
@@ -320,7 +334,7 @@ PostgreSQL 周期对账才是正确性基础。
| Client Key 金额窗口与费用事件 | PostgreSQL | 准入与幂等结算的权威账本,独立于请求观测与日志保留策略 |
| admission、lease、cooldown、circuit、会话亲和、continuation、OAuth pending、目录 cache | Redis | 可重建、可过期的协调状态 |
| 日志、OAuth 恢复记录、在线更新状态、备份暂存 | `.runtime/` | 部署节点本地运行文件 |
| 重置卡库存与消费结果 | OpenAI upstream | 后端不建立本地卡库存;前端只保留当前浏览器会话的最近查询 |
| 重置卡库存与消费结果 | OpenAI upstream | 后端不建立本地卡库存;前端按账号保留当前浏览器会话的最近查询、未决消费幂等键与发送锁,展开行卸载不会清空 |
| Provider 公开模型与请求画像 | Provider/runtime cache | 由官方目录或发布源刷新,不写成第二份业务配置 |
| Windows 安装包临时直链 | Host 进程内短缓存 | 按需解析、严格校验、到期前丢弃;不写 PostgreSQL/Redis,也不代理包字节 |
@@ -346,7 +360,9 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
原工具类型及对应 item ID,`call_id` 始终保持配对。转换仅处理协议字段,超限或转换失败终止流。
默认 `store: false` 的续接使用现有会话 owner 重放完整历史;上一轮要求上游存储且指令未变时,
原生续接移除本轮 `instructions`,避免 Grok 拒绝与 `previous_response_id` 同传。
- 新账号导入和首次 OAuth 在 credential 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
- 账号导入和 OAuth complete(包括重新授权)在 credential 提交、Provider 事实失效及快照发布后,
由 Admin 共用流程后台读取一次额度;不等待观测完成才返回管理请求,失败记录告警但不回滚账号事务。
手工和后台 credential refresh 仍不隐式刷新 quota。
- quota refresh、正常推理返回的 rate-limit headers 和后台健康任务汇入同一额度事实;套餐只用于展示与
目录 cache 隔离,不创建套餐专属状态机。
@@ -8,6 +8,7 @@ import { getAccountModels, refreshAccountModels } from '@/api'
import { API_BASE_URL } from '@/api/constants'
import { toast } from '@/components/base/BaseToast'
import { useIdSet } from '@/composables/useIdSet'
import { useRequestState } from '@/composables/useRequestState'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { formatDateTime, formatTime } from '@/utils/date'
@@ -162,8 +163,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
const connectionTestFinishedAt = shallowRef('')
const connectionTestDurationMs = shallowRef<number | null>(null)
const testingConnections = useIdSet<string>()
const loadingConnectionTestModels = shallowRef(false)
const refreshingConnectionTestModels = shallowRef(false)
const modelsRequest = useRequestState()
const modelsRequestMode = shallowRef<'load' | 'refresh'>('load')
const loadingConnectionTestModels = computed(() => modelsRequest.loading.value && modelsRequestMode.value === 'load')
const refreshingConnectionTestModels = computed(() => modelsRequest.loading.value && modelsRequestMode.value === 'refresh')
const connectionTestSelectedModel = shallowRef('')
const connectionTestModelOptions = ref<ConnectionTestModelOption[]>([])
const connectionTestStreamUrl = shallowRef<string>()
@@ -396,25 +399,39 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
clearConnectionTestRun()
}
async function loadConnectionTestModels(account = testingAccount.value) {
if (!account?.id)
async function loadConnectionTestModels(account = testingAccount.value, refresh = false) {
if (!account?.id || !showConnectionTestModal.value || account.id !== testingAccount.value?.id)
return
loadingConnectionTestModels.value = true
// 初次查询与主动刷新属于同一弹窗会话,旧请求不得覆盖新账号或新一轮查询。
const requestId = modelsRequest.start()
modelsRequestMode.value = refresh ? 'refresh' : 'load'
connectionTestError.value = ''
try {
const result = await getAccountModels({ accountId: account.id })
applyConnectionTestModels(result)
if (!connectionTestSelectedModel.value) {
const result = await (refresh ? refreshAccountModels : getAccountModels)({ accountId: account.id })
if (!modelsRequest.isCurrent(requestId))
return
applyConnectionTestModels(result, refresh)
if (refresh) {
toast.success(`已刷新 ${connectionTestModelOptions.value.length} 个上游模型`)
}
else if (!connectionTestSelectedModel.value) {
connectionTestError.value = '没有可测试模型'
}
}
catch (error: unknown) {
connectionTestError.value = errorMessage(error, '加载测试模型失败')
connectionTestModelOptions.value = []
connectionTestSelectedModel.value = ''
if (!modelsRequest.isCurrent(requestId))
return
connectionTestError.value = errorMessage(error, refresh ? '刷新上游模型失败' : '加载测试模型失败')
if (refresh) {
toast.error(connectionTestError.value)
}
else {
connectionTestModelOptions.value = []
connectionTestSelectedModel.value = ''
}
}
finally {
loadingConnectionTestModels.value = false
modelsRequest.finish(requestId)
}
}
@@ -435,22 +452,9 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
}
async function handleRefreshConnectionTestModels(account = testingAccount.value) {
if (!account?.id || refreshingConnectionTestModels.value)
if (modelsRequest.loading.value)
return
refreshingConnectionTestModels.value = true
connectionTestError.value = ''
try {
const result = await refreshAccountModels({ accountId: account.id })
applyConnectionTestModels(result, true)
toast.success(`已刷新 ${connectionTestModelOptions.value.length} 个上游模型`)
}
catch (error: unknown) {
connectionTestError.value = errorMessage(error, '刷新上游模型失败')
toast.error(connectionTestError.value)
}
finally {
refreshingConnectionTestModels.value = false
}
await loadConnectionTestModels(account, true)
}
async function handleTestConnection(account = testingAccount.value) {
@@ -524,11 +528,12 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
failConnectionTest('测试连接已断开')
})
watch(showConnectionTestModal, (open) => {
watch([showConnectionTestModal, () => testingAccount.value?.id], ([open]) => {
modelsRequest.invalidate()
if (!open) {
abortConnectionTest()
}
})
}, { flush: 'sync' })
onBeforeUnmount(() => {
abortConnectionTest()
@@ -23,7 +23,7 @@ export function useAccountMutations(options: {
accounts: Ref<AccountRow[]>
selectedIds: Ref<Set<string>>
reload: () => Promise<unknown>
replaceAccount: (account: AccountRow) => boolean
replaceAccount: (account: AccountRow) => Promise<boolean>
}) {
const loadAccounts = options.reload
const { downloadJson } = useDownload()
@@ -174,7 +174,7 @@ export function useAccountMutations(options: {
await refreshingQuotaAccounts.run(accountId, async () => {
try {
const result = await withMinimumDuration(() => refreshAccountQuota({ accountId }))
const remainsVisible = options.replaceAccount(result.account)
const remainsVisible = await options.replaceAccount(result.account)
if (!remainsVisible) {
const selectedIds = new Set(options.selectedIds.value)
selectedIds.delete(accountId)
@@ -192,7 +192,7 @@ export function useAccountMutations(options: {
await recoveringAccounts.run(accountId, async () => {
try {
const result = await withMinimumDuration(() => recoverAccount({ accountId }))
const remainsVisible = options.replaceAccount(result.account)
const remainsVisible = await options.replaceAccount(result.account)
if (!remainsVisible) {
const selectedIds = new Set(options.selectedIds.value)
selectedIds.delete(accountId)
@@ -1,5 +1,5 @@
import type { Account, AccountResetCredit } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { computed, shallowReactive, shallowRef, watch } from 'vue'
import {
consumeAccountResetCredit,
@@ -23,23 +23,75 @@ interface ResetCreditsSnapshot {
availableCount: number
}
// 只保留本次前端会话中由用户主动查询得到的结果,避免账号列表渲染触发上游请求。
const snapshotsByAccountId = new Map<string, ResetCreditsSnapshot>()
interface ResetCreditsSession {
accountId: string
snapshot: ResetCreditsSnapshot | null
pendingOperation: PendingResetCreditOperation | null
consuming: boolean
loading: boolean
loadError: string
loadSequence: number
accountUpdatedListeners: Set<(account: Account) => void>
}
// 库存仍以主动查询的上游结果为准;未决操作和消费锁必须跨展开行卸载存续。
const sessionsByAccountId = new Map<string, ResetCreditsSession>()
function getResetCreditsSession(accountId: string) {
let session = sessionsByAccountId.get(accountId)
if (!session) {
session = shallowReactive<ResetCreditsSession>({
accountId,
snapshot: null,
pendingOperation: null,
consuming: false,
loading: false,
loadError: '',
loadSequence: 0,
accountUpdatedListeners: new Set(),
})
sessionsByAccountId.set(accountId, session)
}
return session
}
async function loadSessionCredits(session: ResetCreditsSession) {
const sequence = ++session.loadSequence
session.loading = true
session.loadError = ''
try {
const result = await getAccountResetCredits({ accountId: session.accountId })
if (sequence !== session.loadSequence)
return
session.snapshot = {
credits: result.credits,
availableCount: Math.max(0, result.availableCount),
}
}
catch (error: unknown) {
if (sequence === session.loadSequence)
session.loadError = errorMessage(error, '重置卡查询失败')
}
finally {
if (sequence === session.loadSequence)
session.loading = false
}
}
export function useAccountResetCredits(options: {
accountId: () => string
onAccountUpdated: (account: Account) => void
}) {
const credits = shallowRef<AccountResetCredit[]>([])
const availableCount = shallowRef(0)
const hasSnapshot = shallowRef(false)
const loading = shallowRef(false)
const consuming = shallowRef(false)
const loadError = shallowRef('')
const session = shallowRef(getResetCreditsSession(options.accountId()))
const credits = computed(() => session.value.snapshot?.credits ?? [])
const availableCount = computed(() => session.value.snapshot?.availableCount ?? 0)
const hasSnapshot = computed(() => session.value.snapshot !== null)
const loading = computed(() => session.value.loading)
const consuming = computed(() => session.value.consuming)
const loadError = computed(() => session.value.loadError)
const showConfirm = shallowRef(false)
const selectedCreditId = shallowRef('')
const pendingOperation = shallowRef<PendingResetCreditOperation | null>(null)
let loadSequence = 0
const pendingOperation = computed(() => session.value.pendingOperation)
const availableCredits = computed(() =>
credits.value.filter(credit => credit.status === 'available'),
@@ -55,12 +107,9 @@ export function useAccountResetCredits(options: {
ambiguous.value || (availableCount.value > 0 && selectedCredit.value !== undefined),
)
function reconcileSelectedCredit(accountId: string) {
if (accountId !== options.accountId())
return
function reconcileSelectedCredit() {
const operation = pendingOperation.value
if (operation?.accountId === accountId) {
if (operation) {
selectedCreditId.value = operation.creditId
return
}
@@ -69,24 +118,6 @@ export function useAccountResetCredits(options: {
selectedCreditId.value = ''
}
function applySnapshot(accountId: string, snapshot: ResetCreditsSnapshot) {
snapshotsByAccountId.set(accountId, snapshot)
if (accountId !== options.accountId())
return
credits.value = snapshot.credits
availableCount.value = snapshot.availableCount
hasSnapshot.value = true
reconcileSelectedCredit(accountId)
}
function restoreSnapshot(accountId: string) {
const snapshot = snapshotsByAccountId.get(accountId)
credits.value = snapshot?.credits ?? []
availableCount.value = snapshot?.availableCount ?? 0
hasSnapshot.value = snapshot !== undefined
reconcileSelectedCredit(accountId)
}
function selectCredit(creditId: string) {
if (pendingOperation.value || consuming.value)
return
@@ -95,77 +126,52 @@ export function useAccountResetCredits(options: {
selectedCreditId.value = creditId
}
function applyConfirmedConsumption(operation: PendingResetCreditOperation) {
const snapshot = snapshotsByAccountId.get(operation.accountId)
function applyConfirmedConsumption(target: ResetCreditsSession, operation: PendingResetCreditOperation) {
const snapshot = target.snapshot
if (!snapshot)
return
const creditIndex = snapshot.credits.findIndex(credit => credit.id === operation.creditId)
const nextCredits = creditIndex < 0
? snapshot.credits
: snapshot.credits.filter((_, index) => index !== creditIndex)
applySnapshot(operation.accountId, {
target.snapshot = {
credits: nextCredits,
availableCount: Math.max(0, snapshot.availableCount - 1),
})
}
async function loadCredits() {
const accountId = options.accountId()
const sequence = ++loadSequence
loading.value = true
loadError.value = ''
try {
const result = await getAccountResetCredits({ accountId })
if (sequence !== loadSequence || accountId !== options.accountId())
return
applySnapshot(accountId, {
credits: result.credits,
availableCount: Math.max(0, result.availableCount),
})
}
catch (error: unknown) {
if (sequence !== loadSequence || accountId !== options.accountId())
return
loadError.value = errorMessage(error, '重置卡查询失败')
}
finally {
if (sequence === loadSequence)
loading.value = false
}
}
function requestConsume() {
const accountId = options.accountId()
const existing = pendingOperation.value
if (!existing || existing.accountId !== accountId) {
if (availableCount.value <= 0)
return
const credit = selectedCredit.value
if (!credit)
return
pendingOperation.value = {
accountId,
creditId: credit.id,
credit,
redeemRequestId: generateRedeemRequestId(),
hasTransportFailure: false,
}
}
if (consuming.value || loading.value || !canRequestConsume.value)
return
showConfirm.value = true
}
function cancelConsume() {
if (consuming.value)
return
showConfirm.value = false
if (!pendingOperation.value?.hasTransportFailure)
pendingOperation.value = null
}
async function confirmConsume(): Promise<boolean> {
const operation = pendingOperation.value
if (!operation || consuming.value)
const target = session.value
if (!showConfirm.value || target.consuming || target.loading)
return false
consuming.value = true
const credit = selectedCredit.value
// 确认发送时才建立操作;仅打开或取消确认弹窗不占用账号的消费状态。
const operation = target.pendingOperation ?? (credit && availableCount.value > 0
? {
accountId: target.accountId,
creditId: credit.id,
credit,
redeemRequestId: generateRedeemRequestId(),
hasTransportFailure: false,
}
: null)
if (!operation)
return false
target.pendingOperation = operation
target.consuming = true
try {
const result = await consumeAccountResetCredit({
accountId: operation.accountId,
@@ -174,23 +180,23 @@ export function useAccountResetCredits(options: {
})
const confirmed = result.code === 'reset'
|| (result.code === 'already_redeemed' && operation.hasTransportFailure)
pendingOperation.value = null
showConfirm.value = false
target.pendingOperation = null
if (!confirmed) {
toast.error(resetResultMessage(result.code))
await loadCredits()
await loadSessionCredits(target)
return false
}
const successMessage = result.code === 'already_redeemed'
? '上次重置已完成'
: '额度已重置'
applyConfirmedConsumption(operation)
await loadCredits()
applyConfirmedConsumption(target, operation)
await loadSessionCredits(target)
try {
const quota = await refreshAccountQuota({ accountId: operation.accountId })
if (operation.accountId === options.accountId())
options.onAccountUpdated(quota.account)
// 原组件可能已经卸载或换号,只通知仍订阅该账号的实例。
for (const listener of target.accountUpdatedListeners)
listener(quota.account)
toast.success(successMessage)
}
catch (error: unknown) {
@@ -202,40 +208,45 @@ export function useAccountResetCredits(options: {
return true
}
catch (error: unknown) {
showConfirm.value = false
if (isAmbiguousConsumeError(error)) {
pendingOperation.value = {
target.pendingOperation = {
...operation,
hasTransportFailure: true,
}
toast.warning('消费结果暂不确定;重试会复用同一个请求标识', { duration: 5000 })
}
else {
pendingOperation.value = null
target.pendingOperation = null
toast.error(errorMessage(error, '额度重置失败'))
await loadCredits()
await loadSessionCredits(target)
}
return false
}
finally {
consuming.value = false
target.consuming = false
}
}
watch(
options.accountId,
(accountId) => {
loadSequence += 1
pendingOperation.value = null
(accountId, _, onCleanup) => {
const target = getResetCreditsSession(accountId)
session.value = target
selectedCreditId.value = ''
restoreSnapshot(accountId)
loading.value = false
loadError.value = ''
showConfirm.value = false
const listener = (account: Account) => options.onAccountUpdated(account)
target.accountUpdatedListeners.add(listener)
onCleanup(() => target.accountUpdatedListeners.delete(listener))
},
{ immediate: true },
{ immediate: true, flush: 'sync' },
)
watch([credits, pendingOperation], reconcileSelectedCredit, { immediate: true, flush: 'sync' })
watch(consuming, (isConsuming) => {
if (!isConsuming)
showConfirm.value = false
}, { flush: 'sync' })
return {
credits,
availableCredits,
@@ -249,7 +260,7 @@ export function useAccountResetCredits(options: {
loadError,
ambiguous,
showConfirm,
loadCredits,
loadCredits: () => loadSessionCredits(session.value),
selectCredit,
requestConsume,
cancelConsume,
@@ -8,16 +8,6 @@ import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type AccountSummary = Awaited<ReturnType<typeof getAccounts>>['summary']
type AccountSummaryStatusKey = Exclude<keyof AccountSummary, 'total'>
const summaryKeyByStatus = {
normal: 'normal',
quota_exhausted: 'quotaExhausted',
rate_limited: 'rateLimited',
disabled: 'disabled',
error: 'error',
} as const satisfies Record<AccountRow['status'], AccountSummaryStatusKey>
export function useAccountsQuery() {
const searchQuery = shallowRef('')
@@ -78,42 +68,12 @@ export function useAccountsQuery() {
void query.execute()
}
function replaceAccount(updated: AccountRow) {
const index = query.items.value.findIndex(account => account.id === updated.id)
if (index < 0)
return false
const current = query.items.value[index]
if (!current)
return false
updateAccountSummary(current.status, updated.status)
if (statusQuery.value && statusQuery.value !== updated.status) {
query.items.value = query.items.value.filter(account => account.id !== updated.id)
query.total.value = Math.max(0, query.total.value - 1)
return false
}
const accounts = [...query.items.value]
accounts[index] = updated
query.items.value = accounts
return true
}
function updateAccountSummary(
previousStatus: AccountRow['status'],
nextStatus: AccountRow['status'],
) {
if (previousStatus === nextStatus)
return
const previousKey = summaryKeyByStatus[previousStatus]
const nextKey = summaryKeyByStatus[nextStatus]
const summary = { ...accountSummary.value }
summary[previousKey] = Math.max(0, summary[previousKey] - 1)
summary[nextKey] += 1
accountSummary.value = summary
async function replaceAccount(updated: AccountRow) {
// 状态变更可能影响筛选、排序及全局概览,统一回读并复用分页查询的末页回退。
query.invalidate()
if (!await query.execute())
return true // 回读失败或被新查询取代时,不依据旧页面取消选择。
return query.items.value.some(account => account.id === updated.id)
}
watchDebounced(
+1 -1
View File
@@ -295,7 +295,7 @@ const {
<AccountQuotaPanel
:account="row"
:refreshing="refreshingQuotaAccountIds.has(row.id)"
@account-updated="replaceAccount"
@account-updated="void replaceAccount($event)"
@refresh-quota="handleRefreshQuota"
/>
<AccountUsagePanel :account="row" />