mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-12 00:06:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5168f6a924 | ||
|
|
3a407c635a | ||
|
|
6901e0ed45 | ||
|
|
9758c420fa | ||
|
|
6d2bf06ac7 | ||
|
|
b8faaf250f | ||
|
|
40137b1205 |
@@ -9,17 +9,8 @@ use gateway_core::routing::ProviderKind;
|
||||
|
||||
use super::{PageSize, Revision, observability::TimeRange};
|
||||
|
||||
/// Provider 账号当前可用性。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountAvailability {
|
||||
Unknown,
|
||||
Ready,
|
||||
Cooldown,
|
||||
QuotaExhausted,
|
||||
Expired,
|
||||
Banned,
|
||||
Invalid,
|
||||
}
|
||||
/// Provider 账号当前可用性;权威定义与字符串编解码在 gateway-core,此处仅复用。
|
||||
pub use gateway_core::engine::credential::AccountAvailability;
|
||||
|
||||
/// 管理页使用的归一化账号状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -347,9 +347,13 @@ pub(super) struct FakeAccountStore {
|
||||
|
||||
impl FakeAccountStore {
|
||||
pub(super) fn new(kind: &str, events: EventLog) -> Arc<Self> {
|
||||
Self::with_account(account_record(kind), events)
|
||||
}
|
||||
|
||||
fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
events,
|
||||
account: account_record(kind),
|
||||
account,
|
||||
fail_commit: Mutex::new(false),
|
||||
audit_requests: Mutex::new(Vec::new()),
|
||||
quota_window_usage: Mutex::new(Vec::new()),
|
||||
@@ -686,9 +690,11 @@ fn provider_registry_should_reject_duplicate_kind() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_test_should_preserve_safe_provider_error_details() {
|
||||
async fn connection_test_should_probe_unavailable_account() {
|
||||
let provider = FakeProviderAdmin::new("xai", events());
|
||||
let store = FakeAccountStore::new("xai", events());
|
||||
let mut account = account_record("xai");
|
||||
account.availability = AccountAvailability::QuotaExhausted;
|
||||
let store = FakeAccountStore::with_account(account, events());
|
||||
let services =
|
||||
accounts_service_with_probe(provider, store, Arc::new(FailingAccountProbe)).await;
|
||||
|
||||
@@ -709,7 +715,7 @@ async fn connection_test_should_preserve_safe_provider_error_details() {
|
||||
message,
|
||||
provider_error_code: Some(code),
|
||||
provider_error_type: Some(error_type),
|
||||
..
|
||||
account_status: gateway_admin::model::accounts::AccountStatus::QuotaExhausted,
|
||||
}) if message == "included usage exhausted"
|
||||
&& code == "usage_exhausted"
|
||||
&& error_type == "invalid_request_error"
|
||||
|
||||
@@ -18,9 +18,9 @@ use futures::{Stream, StreamExt as _};
|
||||
use gateway_admin::model::{
|
||||
AdminError as AdminServiceError, AdminErrorKind, PageSize, Revision,
|
||||
accounts::{
|
||||
AccountAvailability, AccountConnectionTestEvent as DomainConnectionTestEvent, AccountCost,
|
||||
AccountListQuery, AccountModelUsage, AccountSort, AccountSortField,
|
||||
AccountStatus as DomainAccountStatus, AccountUsage, SortDirection,
|
||||
AccountConnectionTestEvent as DomainConnectionTestEvent, AccountCost, AccountListQuery,
|
||||
AccountModelUsage, AccountSort, AccountSortField, AccountStatus as DomainAccountStatus,
|
||||
AccountUsage, SortDirection,
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
@@ -1407,7 +1407,7 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
|
||||
status: status.clone(),
|
||||
display_status: status,
|
||||
token_refreshing: false,
|
||||
availability: account_availability_name(account.availability).to_owned(),
|
||||
availability: account.availability.as_str().to_owned(),
|
||||
enabled: account.enabled,
|
||||
credential_revision: account.credential_revision.get(),
|
||||
state_revision: None,
|
||||
@@ -1752,18 +1752,6 @@ fn account_status_name(status: DomainAccountStatus) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn account_availability_name(availability: AccountAvailability) -> &'static str {
|
||||
match availability {
|
||||
AccountAvailability::Unknown => "unknown",
|
||||
AccountAvailability::Ready => "ready",
|
||||
AccountAvailability::Cooldown => "cooldown",
|
||||
AccountAvailability::QuotaExhausted => "quota_exhausted",
|
||||
AccountAvailability::Expired => "expired",
|
||||
AccountAvailability::Banned => "banned",
|
||||
AccountAvailability::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(value: u64) -> String {
|
||||
let text = value.to_string();
|
||||
let mut output = String::with_capacity(text.len() + text.len() / 3);
|
||||
|
||||
@@ -75,10 +75,14 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
|
||||
if error.kind() == ProviderErrorKind::Unavailable
|
||||
&& error.send_state() == UpstreamSendState::NotSent =>
|
||||
{
|
||||
GatewayError::new(
|
||||
let gateway = GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request",
|
||||
)
|
||||
);
|
||||
match error.client_visible_upstream_error() {
|
||||
Some(upstream) => gateway.with_client_visible_upstream_error(upstream.clone()),
|
||||
None => gateway,
|
||||
}
|
||||
}
|
||||
EngineError::Provider(error) => GatewayError::from_provider(error),
|
||||
EngineError::Cancelled => {
|
||||
|
||||
@@ -135,6 +135,36 @@ fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_capacity_exhaustion_should_preserve_safe_provider_detail() {
|
||||
let detail = ClientVisibleUpstreamError::new(
|
||||
"no account is eligible for the requested model",
|
||||
Some("no_eligible_account".to_owned()),
|
||||
Some("account_unavailable_error".to_owned()),
|
||||
)
|
||||
.expect("safe provider detail");
|
||||
let error = EngineError::Provider(
|
||||
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
|
||||
.with_client_visible_upstream_error(detail),
|
||||
);
|
||||
let gateway = gateway_error_from_engine(&error);
|
||||
|
||||
assert_eq!(gateway.kind(), GatewayErrorKind::NoAvailableProvider);
|
||||
assert_eq!(
|
||||
gateway.client_message(),
|
||||
"no account is eligible for the requested model"
|
||||
);
|
||||
assert_eq!(gateway.client_error_code(), Some("no_eligible_account"));
|
||||
assert_eq!(
|
||||
gateway.client_error_type(),
|
||||
Some("account_unavailable_error")
|
||||
);
|
||||
assert_eq!(
|
||||
gateway.safe_message(),
|
||||
"no upstream provider is currently available for this request"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_eligible_provider_account_should_map_to_service_unavailable() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
|
||||
@@ -12,8 +12,7 @@ use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, Revision as AdminRevision,
|
||||
accounts::{
|
||||
AccountAvailability as AdminAccountAvailability, AccountCost,
|
||||
AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
|
||||
AccountCost, AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
|
||||
AccountRecord, AccountRequestBucket, AccountSort as AdminAccountSort,
|
||||
AccountSortField as AdminAccountSortField, AccountStatus as AdminAccountStatus,
|
||||
AccountSummary, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
|
||||
@@ -35,8 +34,8 @@ use gateway_admin::{
|
||||
use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability as CoreAccountAvailability, AccountStateChange, CredentialCasOutcome,
|
||||
CredentialCasUpdate, CredentialRevision as CoreCredentialRevision, LoadedCredential,
|
||||
AccountAvailability, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
|
||||
CredentialRevision as CoreCredentialRevision, LoadedCredential,
|
||||
NewProviderAccount as CoreNewProviderAccount, OpaqueProviderData, PlaintextCredential,
|
||||
ProviderAccount as CoreProviderAccount, ProviderAccountId as CoreProviderAccountId,
|
||||
ProviderAccountStore, ProviderAccountUpdate as CoreProviderAccountUpdate, QuotaObservation,
|
||||
@@ -121,43 +120,9 @@ impl ProviderAccountAdminScope {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAccountAvailability {
|
||||
Unknown,
|
||||
Ready,
|
||||
Cooldown,
|
||||
QuotaExhausted,
|
||||
Expired,
|
||||
Banned,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl ProviderAccountAvailability {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ready => "ready",
|
||||
Self::Cooldown => "cooldown",
|
||||
Self::QuotaExhausted => "quota_exhausted",
|
||||
Self::Expired => "expired",
|
||||
Self::Banned => "banned",
|
||||
Self::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: &str) -> StoreResult<Self> {
|
||||
match value {
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
"ready" => Ok(Self::Ready),
|
||||
"cooldown" => Ok(Self::Cooldown),
|
||||
"quota_exhausted" => Ok(Self::QuotaExhausted),
|
||||
"expired" => Ok(Self::Expired),
|
||||
"banned" => Ok(Self::Banned),
|
||||
"invalid" => Ok(Self::Invalid),
|
||||
_ => Err(invalid("unknown availability value")),
|
||||
}
|
||||
}
|
||||
/// 解析数据库 availability 列;字符串映射本体唯一归属 gateway-core。
|
||||
fn parse_availability(value: &str) -> StoreResult<AccountAvailability> {
|
||||
AccountAvailability::parse(value).ok_or_else(|| invalid("unknown availability value"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -175,7 +140,7 @@ pub struct ProviderAccountSummary {
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
pub enabled: bool,
|
||||
pub availability: ProviderAccountAvailability,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
@@ -220,7 +185,7 @@ pub struct NewProviderAccount {
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
pub enabled: bool,
|
||||
pub availability: ProviderAccountAvailability,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
@@ -259,9 +224,7 @@ impl NewProviderAccount {
|
||||
if !self.has_refresh_token && self.next_refresh_at.is_some() {
|
||||
return Err(invalid("next_refresh_at requires a refresh token"));
|
||||
}
|
||||
if (self.availability == ProviderAccountAvailability::Cooldown)
|
||||
!= self.cooldown_until.is_some()
|
||||
{
|
||||
if (self.availability == AccountAvailability::Cooldown) != self.cooldown_until.is_some() {
|
||||
return Err(invalid(
|
||||
"cooldown_until must be present exactly for cooldown availability",
|
||||
));
|
||||
@@ -394,7 +357,7 @@ impl fmt::Debug for ProviderCredentialUpdate {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderAccountObservation {
|
||||
pub account_id: String,
|
||||
pub availability: ProviderAccountAvailability,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub provider_quota_json: Option<JsonObject>,
|
||||
@@ -406,7 +369,7 @@ pub struct ProviderAccountObservation {
|
||||
pub struct ProviderAccountStateUpdate {
|
||||
pub account_id: String,
|
||||
pub expected_revision: Revision,
|
||||
pub availability: ProviderAccountAvailability,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
@@ -415,9 +378,7 @@ pub struct ProviderAccountStateUpdate {
|
||||
impl ProviderAccountStateUpdate {
|
||||
pub fn validate(&self) -> StoreResult<()> {
|
||||
require_nonempty(ENTITY, "account_id", &self.account_id)?;
|
||||
if (self.availability == ProviderAccountAvailability::Cooldown)
|
||||
!= self.cooldown_until.is_some()
|
||||
{
|
||||
if (self.availability == AccountAvailability::Cooldown) != self.cooldown_until.is_some() {
|
||||
return Err(invalid(
|
||||
"cooldown availability and cooldown_until must agree",
|
||||
));
|
||||
@@ -429,9 +390,7 @@ impl ProviderAccountStateUpdate {
|
||||
impl ProviderAccountObservation {
|
||||
pub fn validate(&self) -> StoreResult<()> {
|
||||
require_nonempty(ENTITY, "account_id", &self.account_id)?;
|
||||
if (self.availability == ProviderAccountAvailability::Cooldown)
|
||||
!= self.cooldown_until.is_some()
|
||||
{
|
||||
if (self.availability == AccountAvailability::Cooldown) != self.cooldown_until.is_some() {
|
||||
return Err(invalid(
|
||||
"cooldown availability and cooldown_until must agree",
|
||||
));
|
||||
@@ -1225,9 +1184,10 @@ impl AccountStore for PgAdminAccountStore {
|
||||
.into_iter()
|
||||
.filter(|account| account.provider_kind == provider_kind.as_str())
|
||||
.filter(|account| {
|
||||
query.availability.as_ref().is_none_or(|expected| {
|
||||
expected.matches(admin_account_availability(account.availability))
|
||||
})
|
||||
query
|
||||
.availability
|
||||
.as_ref()
|
||||
.is_none_or(|expected| expected.matches(account.availability))
|
||||
})
|
||||
.filter(|account| {
|
||||
query
|
||||
@@ -1602,12 +1562,12 @@ fn admin_account_status(
|
||||
) -> AdminAccountStatus {
|
||||
if !account.enabled {
|
||||
AdminAccountStatus::Disabled
|
||||
} else if account.availability == ProviderAccountAvailability::Banned {
|
||||
} else if account.availability == AccountAvailability::Banned {
|
||||
AdminAccountStatus::Banned
|
||||
} else if account.availability == ProviderAccountAvailability::QuotaExhausted {
|
||||
} else if account.availability == AccountAvailability::QuotaExhausted {
|
||||
AdminAccountStatus::QuotaExhausted
|
||||
} else if account.availability == ProviderAccountAvailability::Expired
|
||||
|| account.availability == ProviderAccountAvailability::Invalid
|
||||
} else if account.availability == AccountAvailability::Expired
|
||||
|| account.availability == AccountAvailability::Invalid
|
||||
|| account
|
||||
.access_token_expires_at
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
@@ -1732,7 +1692,7 @@ fn admin_account_record(summary: ProviderAccountSummary) -> AdminStoreResult<Acc
|
||||
access_token_expires_at: summary.access_token_expires_at,
|
||||
next_refresh_at: summary.next_refresh_at,
|
||||
enabled: summary.enabled,
|
||||
availability: admin_account_availability(summary.availability),
|
||||
availability: summary.availability,
|
||||
availability_reason: summary.availability_reason,
|
||||
cooldown_until: summary.cooldown_until,
|
||||
availability_observed_at: summary.availability_observed_at,
|
||||
@@ -1757,7 +1717,7 @@ fn prepared_account(credential: PreparedCredentialCreate) -> StoreResult<NewProv
|
||||
access_token_expires_at: credential.access_token_expires_at,
|
||||
next_refresh_at: credential.next_refresh_at,
|
||||
enabled: credential.enabled,
|
||||
availability: provider_account_availability(credential.availability),
|
||||
availability: credential.availability,
|
||||
availability_reason: credential.availability_reason,
|
||||
cooldown_until: credential.cooldown_until,
|
||||
availability_observed_at: credential.availability_observed_at,
|
||||
@@ -1772,34 +1732,6 @@ fn provider_document_json(document: ProviderDocument) -> StoreResult<JsonObject>
|
||||
)
|
||||
}
|
||||
|
||||
const fn provider_account_availability(
|
||||
availability: AdminAccountAvailability,
|
||||
) -> ProviderAccountAvailability {
|
||||
match availability {
|
||||
AdminAccountAvailability::Unknown => ProviderAccountAvailability::Unknown,
|
||||
AdminAccountAvailability::Ready => ProviderAccountAvailability::Ready,
|
||||
AdminAccountAvailability::Cooldown => ProviderAccountAvailability::Cooldown,
|
||||
AdminAccountAvailability::QuotaExhausted => ProviderAccountAvailability::QuotaExhausted,
|
||||
AdminAccountAvailability::Expired => ProviderAccountAvailability::Expired,
|
||||
AdminAccountAvailability::Banned => ProviderAccountAvailability::Banned,
|
||||
AdminAccountAvailability::Invalid => ProviderAccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
const fn admin_account_availability(
|
||||
availability: ProviderAccountAvailability,
|
||||
) -> AdminAccountAvailability {
|
||||
match availability {
|
||||
ProviderAccountAvailability::Unknown => AdminAccountAvailability::Unknown,
|
||||
ProviderAccountAvailability::Ready => AdminAccountAvailability::Ready,
|
||||
ProviderAccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
|
||||
ProviderAccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
|
||||
ProviderAccountAvailability::Expired => AdminAccountAvailability::Expired,
|
||||
ProviderAccountAvailability::Banned => AdminAccountAvailability::Banned,
|
||||
ProviderAccountAvailability::Invalid => AdminAccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_account_usage(usage: ProviderAccountUsageObservation) -> AdminStoreResult<AccountUsage> {
|
||||
Ok(AccountUsage {
|
||||
account_id: usage.account_id,
|
||||
@@ -2214,7 +2146,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
.map(DateTime::<Utc>::from),
|
||||
next_refresh_at: account.account.next_refresh_at().map(DateTime::<Utc>::from),
|
||||
enabled: account.account.enabled(),
|
||||
availability: availability_from_core(account.account.availability()),
|
||||
availability: account.account.availability(),
|
||||
availability_reason: None,
|
||||
cooldown_until: account.account.cooldown_until().map(DateTime::<Utc>::from),
|
||||
availability_observed_at: Utc::now(),
|
||||
@@ -2416,7 +2348,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
account_id: change.account_id.as_str().to_owned(),
|
||||
expected_revision: Revision::new(change.expected_revision.get())
|
||||
.map_err(core_store_error)?,
|
||||
availability: availability_from_core(change.availability),
|
||||
availability: change.availability,
|
||||
availability_reason: change.reason,
|
||||
cooldown_until: change.cooldown_until.map(DateTime::<Utc>::from),
|
||||
availability_observed_at: DateTime::<Utc>::from(change.observed_at),
|
||||
@@ -2527,7 +2459,7 @@ fn core_account_from_summary(
|
||||
)
|
||||
.with_runtime_state(
|
||||
summary.enabled,
|
||||
availability_to_core(summary.availability),
|
||||
summary.availability,
|
||||
summary.cooldown_until.map(Into::into),
|
||||
)
|
||||
.with_refresh_schedule(
|
||||
@@ -2536,30 +2468,6 @@ fn core_account_from_summary(
|
||||
))
|
||||
}
|
||||
|
||||
const fn availability_to_core(value: ProviderAccountAvailability) -> CoreAccountAvailability {
|
||||
match value {
|
||||
ProviderAccountAvailability::Unknown => CoreAccountAvailability::Unknown,
|
||||
ProviderAccountAvailability::Ready => CoreAccountAvailability::Ready,
|
||||
ProviderAccountAvailability::Cooldown => CoreAccountAvailability::Cooldown,
|
||||
ProviderAccountAvailability::QuotaExhausted => CoreAccountAvailability::QuotaExhausted,
|
||||
ProviderAccountAvailability::Expired => CoreAccountAvailability::Expired,
|
||||
ProviderAccountAvailability::Banned => CoreAccountAvailability::Banned,
|
||||
ProviderAccountAvailability::Invalid => CoreAccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
const fn availability_from_core(value: CoreAccountAvailability) -> ProviderAccountAvailability {
|
||||
match value {
|
||||
CoreAccountAvailability::Unknown => ProviderAccountAvailability::Unknown,
|
||||
CoreAccountAvailability::Ready => ProviderAccountAvailability::Ready,
|
||||
CoreAccountAvailability::Cooldown => ProviderAccountAvailability::Cooldown,
|
||||
CoreAccountAvailability::QuotaExhausted => ProviderAccountAvailability::QuotaExhausted,
|
||||
CoreAccountAvailability::Expired => ProviderAccountAvailability::Expired,
|
||||
CoreAccountAvailability::Banned => ProviderAccountAvailability::Banned,
|
||||
CoreAccountAvailability::Invalid => ProviderAccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
fn core_store_error(error: StoreError) -> CoreStoreError {
|
||||
let kind = match error {
|
||||
StoreError::Unavailable { .. } => CoreStoreErrorKind::Unavailable,
|
||||
@@ -2600,7 +2508,7 @@ fn account_summary_from_row(row: sqlx::postgres::PgRow) -> StoreResult<ProviderA
|
||||
access_token_expires_at: get(&row, "access_token_expires_at")?,
|
||||
next_refresh_at: get(&row, "next_refresh_at")?,
|
||||
enabled: get(&row, "enabled")?,
|
||||
availability: ProviderAccountAvailability::parse(&availability)?,
|
||||
availability: parse_availability(&availability)?,
|
||||
availability_reason: get(&row, "availability_reason")?,
|
||||
cooldown_until: get(&row, "cooldown_until")?,
|
||||
availability_observed_at: get(&row, "availability_observed_at")?,
|
||||
|
||||
@@ -55,10 +55,7 @@ impl CredentialStateCache {
|
||||
"provider_account_id",
|
||||
&self.provider_account_id,
|
||||
)?;
|
||||
if !matches!(
|
||||
self.availability.as_str(),
|
||||
"unknown" | "ready" | "cooldown" | "quota_exhausted" | "expired" | "banned" | "invalid"
|
||||
) {
|
||||
if AccountAvailability::parse(&self.availability).is_none() {
|
||||
return Err(invalid("availability is not registered"));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -17,9 +17,9 @@ use gateway_admin::{
|
||||
ports::store::AccountStore,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
|
||||
QuotaObservation, QuotaWriteOutcome,
|
||||
AccountAvailability, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccountId, ProviderAccountStore,
|
||||
ProviderAccountUpdate, QuotaObservation, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use gateway_store::{
|
||||
@@ -27,9 +27,9 @@ use gateway_store::{
|
||||
postgres::{
|
||||
AdminAuditActorKind, AdminAuditEvent, DeleteProviderAccounts, ImportProviderAccounts,
|
||||
NewProviderAccount, PgAdminAccountStore, PgProviderAccountRepository,
|
||||
ProviderAccountAdminRepository, ProviderAccountAdminScope, ProviderAccountAvailability,
|
||||
ProviderAccountObservation, ProviderAccountRepository, ProviderCredentialUpdate,
|
||||
RotateProviderAccount, SetProviderAccountEnabled, UpdateProviderAccount,
|
||||
ProviderAccountAdminRepository, ProviderAccountAdminScope, ProviderAccountObservation,
|
||||
ProviderAccountRepository, ProviderCredentialUpdate, RotateProviderAccount,
|
||||
SetProviderAccountEnabled, UpdateProviderAccount,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
@@ -123,15 +123,15 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
|
||||
let mut beta = account("acct_beta", "user-beta");
|
||||
beta.provider_kind = "xai".to_owned();
|
||||
beta.email = Some("beta@example.invalid".to_owned());
|
||||
beta.availability = ProviderAccountAvailability::Banned;
|
||||
beta.availability = AccountAvailability::Banned;
|
||||
let mut charlie = account("acct_charlie", "user-charlie");
|
||||
charlie.email = Some("charlie@example.invalid".to_owned());
|
||||
let mut invalid = account("acct_invalid", "user-invalid");
|
||||
invalid.email = Some("invalid@example.invalid".to_owned());
|
||||
invalid.availability = ProviderAccountAvailability::Invalid;
|
||||
invalid.availability = AccountAvailability::Invalid;
|
||||
let mut quota_exhausted = account("acct_quota_exhausted", "user-quota-exhausted");
|
||||
quota_exhausted.email = Some("quota-exhausted@example.invalid".to_owned());
|
||||
quota_exhausted.availability = ProviderAccountAvailability::QuotaExhausted;
|
||||
quota_exhausted.availability = AccountAvailability::QuotaExhausted;
|
||||
for account in [alpha, beta, charlie, invalid, quota_exhausted] {
|
||||
repository
|
||||
.insert_provider_account(account)
|
||||
@@ -292,10 +292,10 @@ async fn terminal_credential_list_preserves_grouped_filters_and_unpaged_collecti
|
||||
);
|
||||
credential.provider_kind = "xai".to_owned();
|
||||
credential.availability = match index {
|
||||
0 => ProviderAccountAvailability::Expired,
|
||||
1 => ProviderAccountAvailability::Banned,
|
||||
2 => ProviderAccountAvailability::Invalid,
|
||||
_ => ProviderAccountAvailability::Ready,
|
||||
0 => AccountAvailability::Expired,
|
||||
1 => AccountAvailability::Banned,
|
||||
2 => AccountAvailability::Invalid,
|
||||
_ => AccountAvailability::Ready,
|
||||
};
|
||||
repository
|
||||
.insert_provider_account(credential)
|
||||
@@ -644,7 +644,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
let mut updated = account("acct_admin_reimport", "user-admin-upsert");
|
||||
updated.name = "updated import".to_owned();
|
||||
updated.provider_credentials_json = credential_json("updated-import-secret");
|
||||
updated.availability = ProviderAccountAvailability::Banned;
|
||||
updated.availability = AccountAvailability::Banned;
|
||||
updated.availability_reason = Some("upstream_account_deactivated".to_owned());
|
||||
let imported = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
@@ -716,7 +716,7 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
access_token_expires_at: Some(Utc::now() + TimeDelta::minutes(5)),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
availability: ProviderAccountAvailability::Ready,
|
||||
availability: AccountAvailability::Ready,
|
||||
availability_reason: None,
|
||||
cooldown_until: None,
|
||||
availability_observed_at: Utc::now(),
|
||||
@@ -821,7 +821,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
};
|
||||
|
||||
let mut cooldown_account = account("acct_admin_b", "user-admin-b");
|
||||
cooldown_account.availability = ProviderAccountAvailability::Cooldown;
|
||||
cooldown_account.availability = AccountAvailability::Cooldown;
|
||||
cooldown_account.cooldown_until = Some(Utc::now() + TimeDelta::minutes(10));
|
||||
let imported = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
@@ -961,7 +961,7 @@ fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
|
||||
access_token_expires_at: Some(Utc::now() + TimeDelta::hours(1)),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
availability: ProviderAccountAvailability::Ready,
|
||||
availability: AccountAvailability::Ready,
|
||||
availability_reason: None,
|
||||
cooldown_until: None,
|
||||
availability_observed_at: Utc::now(),
|
||||
@@ -1094,7 +1094,7 @@ fn provider_credentials_are_redacted_from_debug() {
|
||||
fn cooldown_observation_requires_expiry() {
|
||||
let observation = ProviderAccountObservation {
|
||||
account_id: "account-1".to_owned(),
|
||||
availability: ProviderAccountAvailability::Cooldown,
|
||||
availability: AccountAvailability::Cooldown,
|
||||
availability_reason: None,
|
||||
cooldown_until: None,
|
||||
provider_quota_json: None,
|
||||
|
||||
@@ -5,9 +5,7 @@ use std::time::SystemTime;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::accounts::{
|
||||
AccountAvailability as AdminAccountAvailability, AccountRecord,
|
||||
};
|
||||
use gateway_admin::model::accounts::AccountRecord;
|
||||
use gateway_admin::model::observability::{
|
||||
CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DecimalAmount, DesktopReleaseStatus,
|
||||
@@ -27,9 +25,8 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
use gateway_core::accounting::Money;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability, CredentialRevision, LoadedCredential, NewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore,
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
@@ -253,11 +250,22 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
command.document.into_provider_data().into_inner(),
|
||||
))
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
log_import_failure("prepare_document", credential_admin_error_code(*error));
|
||||
})
|
||||
.map_err(map_credential_admin_error)?;
|
||||
let observed_at = Utc::now();
|
||||
let mut credentials = Vec::with_capacity(prepared.accounts().len());
|
||||
for account in prepared.into_accounts() {
|
||||
let account = self.preserve_existing_installation_id(account).await?;
|
||||
let account = self
|
||||
.preserve_existing_installation_id(account)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
log_import_failure(
|
||||
"preserve_installation_id",
|
||||
provider_admin_error_code(error.kind()),
|
||||
);
|
||||
})?;
|
||||
credentials.push(prepared_create(account, observed_at)?);
|
||||
}
|
||||
Ok(PreparedCredentialImport {
|
||||
@@ -289,6 +297,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<PreparedAuthorizationCommit, ProviderAdminError> {
|
||||
let request_id = command.context.request_id.clone();
|
||||
let binding = AuthorizationOwnerBinding::from_context(&command.context);
|
||||
let completed = self
|
||||
.oauth
|
||||
@@ -298,6 +307,14 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
callback_url: SecretString::from(command.callback_url),
|
||||
})
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
tracing::warn!(
|
||||
request_id = %request_id,
|
||||
oauth_stage = "complete_authorization",
|
||||
oauth_error = oauth_error_code(error),
|
||||
"OpenAI OAuth authorization completion failed"
|
||||
);
|
||||
})
|
||||
.map_err(map_oauth_error)?;
|
||||
let credential = match completed.credential {
|
||||
CompletedCodexOAuthCredential::Create(credential) => {
|
||||
@@ -519,7 +536,7 @@ fn prepared_create(
|
||||
account,
|
||||
credential,
|
||||
} = prepared;
|
||||
let availability = admin_availability(account.availability());
|
||||
let availability = account.availability();
|
||||
Ok(PreparedCredentialCreate {
|
||||
account_id: account.id().clone(),
|
||||
provider_kind: account.provider().clone(),
|
||||
@@ -639,7 +656,7 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
|
||||
)
|
||||
.with_runtime_state(
|
||||
account.enabled,
|
||||
core_availability(account.availability),
|
||||
account.availability,
|
||||
account.cooldown_until.map(SystemTime::from),
|
||||
)
|
||||
.with_refresh_schedule(
|
||||
@@ -698,7 +715,7 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
|
||||
&& account.plan_type() == record.plan_type.as_deref()
|
||||
&& account.authentication_kind() == record.authentication_kind
|
||||
&& account.enabled() == record.enabled
|
||||
&& admin_availability(account.availability()) == record.availability
|
||||
&& account.availability() == record.availability
|
||||
&& account.cooldown_until().map(DateTime::<Utc>::from) == record.cooldown_until
|
||||
&& account.access_token_expires_at().map(DateTime::<Utc>::from)
|
||||
== record.access_token_expires_at
|
||||
@@ -706,30 +723,6 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
|
||||
&& account.has_refresh_token() == record.has_refresh_token
|
||||
}
|
||||
|
||||
const fn admin_availability(value: AccountAvailability) -> AdminAccountAvailability {
|
||||
match value {
|
||||
AccountAvailability::Unknown => AdminAccountAvailability::Unknown,
|
||||
AccountAvailability::Ready => AdminAccountAvailability::Ready,
|
||||
AccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
|
||||
AccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
|
||||
AccountAvailability::Expired => AdminAccountAvailability::Expired,
|
||||
AccountAvailability::Banned => AdminAccountAvailability::Banned,
|
||||
AccountAvailability::Invalid => AdminAccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
const fn core_availability(value: AdminAccountAvailability) -> AccountAvailability {
|
||||
match value {
|
||||
AdminAccountAvailability::Unknown => AccountAvailability::Unknown,
|
||||
AdminAccountAvailability::Ready => AccountAvailability::Ready,
|
||||
AdminAccountAvailability::Cooldown => AccountAvailability::Cooldown,
|
||||
AdminAccountAvailability::QuotaExhausted => AccountAvailability::QuotaExhausted,
|
||||
AdminAccountAvailability::Expired => AccountAvailability::Expired,
|
||||
AdminAccountAvailability::Banned => AccountAvailability::Banned,
|
||||
AdminAccountAvailability::Invalid => AccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
observed_at: None,
|
||||
@@ -1238,6 +1231,44 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
|
||||
})
|
||||
}
|
||||
|
||||
const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'static str {
|
||||
match error {
|
||||
CodexCredentialAdminError::InvalidInput => "invalid_input",
|
||||
CodexCredentialAdminError::IdentityMismatch => "identity_mismatch",
|
||||
CodexCredentialAdminError::InvalidCredential => "invalid_credential",
|
||||
CodexCredentialAdminError::NotFound => "not_found",
|
||||
CodexCredentialAdminError::RevisionConflict => "revision_conflict",
|
||||
CodexCredentialAdminError::StoreUnavailable => "store_unavailable",
|
||||
CodexCredentialAdminError::MissingRefreshToken => "missing_refresh_token",
|
||||
CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable",
|
||||
CodexCredentialAdminError::RefreshRejected => "refresh_rejected",
|
||||
CodexCredentialAdminError::AccountBanned => "account_banned",
|
||||
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
|
||||
CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous",
|
||||
CodexCredentialAdminError::IdentityRejected => "identity_rejected",
|
||||
CodexCredentialAdminError::IdentityUnavailable => "identity_unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
const fn provider_admin_error_code(kind: ProviderAdminErrorKind) -> &'static str {
|
||||
match kind {
|
||||
ProviderAdminErrorKind::Invalid => "invalid",
|
||||
ProviderAdminErrorKind::Unsupported => "unsupported",
|
||||
ProviderAdminErrorKind::NotFound => "not_found",
|
||||
ProviderAdminErrorKind::Conflict => "conflict",
|
||||
ProviderAdminErrorKind::Unavailable => "unavailable",
|
||||
ProviderAdminErrorKind::Internal => "internal",
|
||||
}
|
||||
}
|
||||
|
||||
fn log_import_failure(stage: &'static str, error: &'static str) {
|
||||
tracing::warn!(
|
||||
import_stage = stage,
|
||||
import_error = error,
|
||||
"OpenAI credential import preparation failed"
|
||||
);
|
||||
}
|
||||
|
||||
fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
|
||||
use CodexOAuthAdminError as Error;
|
||||
provider_admin_error(match error {
|
||||
@@ -1252,6 +1283,20 @@ fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
|
||||
})
|
||||
}
|
||||
|
||||
const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str {
|
||||
match error {
|
||||
CodexOAuthAdminError::InvalidInput => "invalid_input",
|
||||
CodexOAuthAdminError::NotFound => "not_found",
|
||||
CodexOAuthAdminError::Conflict => "conflict",
|
||||
CodexOAuthAdminError::FlowExpired => "flow_expired",
|
||||
CodexOAuthAdminError::UpstreamRejected => "upstream_rejected",
|
||||
CodexOAuthAdminError::UpstreamUnavailable => "upstream_unavailable",
|
||||
CodexOAuthAdminError::Ambiguous => "ambiguous",
|
||||
CodexOAuthAdminError::StorageUnavailable => "storage_unavailable",
|
||||
CodexOAuthAdminError::Credential => "credential",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
|
||||
use CodexCredentialQuotaError as Error;
|
||||
provider_admin_error(match error {
|
||||
|
||||
@@ -635,9 +635,11 @@ fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAut
|
||||
let mut state = None;
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" if code.is_none() => code = Some(value.into_owned()),
|
||||
"state" if state.is_none() => state = Some(value.into_owned()),
|
||||
_ => return Err(CodexOAuthAdminError::UpstreamRejected),
|
||||
"code" => set_unique_callback_parameter(&mut code, value.into_owned())?,
|
||||
"state" => set_unique_callback_parameter(&mut state, value.into_owned())?,
|
||||
// OAuth 服务可能附加 `scope`、`iss` 等标准参数。
|
||||
// 固定回调地址与唯一的 `code`/`state` 才是安全边界,无关参数不参与校验。
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let code = code.filter(|value| valid_secret(value));
|
||||
@@ -648,6 +650,16 @@ fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAut
|
||||
}
|
||||
}
|
||||
|
||||
fn set_unique_callback_parameter(
|
||||
target: &mut Option<String>,
|
||||
value: String,
|
||||
) -> Result<(), CodexOAuthAdminError> {
|
||||
if target.replace(value).is_some() {
|
||||
return Err(CodexOAuthAdminError::UpstreamRejected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn random_secret() -> Result<String, CodexOAuthAdminError> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
getrandom::fill(&mut bytes).map_err(|_| CodexOAuthAdminError::StorageUnavailable)?;
|
||||
@@ -718,8 +730,11 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE
|
||||
}
|
||||
}
|
||||
|
||||
fn map_identity_error(_: CodexIdentityVerificationError) -> CodexOAuthAdminError {
|
||||
CodexOAuthAdminError::UpstreamRejected
|
||||
fn map_identity_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError {
|
||||
match error {
|
||||
CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::UpstreamRejected,
|
||||
CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError {
|
||||
|
||||
@@ -973,13 +973,11 @@ fn merge_passive_quota(
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
for (limit_id, details) in &rate_limits.limits {
|
||||
let Some(rate_limit) = passive_rate_limit_snapshot(details) else {
|
||||
continue;
|
||||
};
|
||||
if Some(limit_id.as_str()) == active_limit {
|
||||
let mut value = quota
|
||||
.remove("rate_limit")
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
merge_rate_limit_details(&mut value, details);
|
||||
quota.insert("rate_limit".to_owned(), Value::Object(value));
|
||||
quota.insert("rate_limit".to_owned(), Value::Object(rate_limit));
|
||||
continue;
|
||||
}
|
||||
let index = additional.iter().position(|item| {
|
||||
@@ -1000,12 +998,7 @@ fn merge_passive_quota(
|
||||
if let Some(name) = details.limit_name.as_ref() {
|
||||
item.insert("limit_name".to_owned(), Value::String(name.clone()));
|
||||
}
|
||||
let mut value = item
|
||||
.remove("rate_limit")
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
merge_rate_limit_details(&mut value, details);
|
||||
item.insert("rate_limit".to_owned(), Value::Object(value));
|
||||
item.insert("rate_limit".to_owned(), Value::Object(rate_limit));
|
||||
match index {
|
||||
Some(index) => additional[index] = Value::Object(item),
|
||||
None => additional.push(Value::Object(item)),
|
||||
@@ -1018,12 +1011,14 @@ fn merge_passive_quota(
|
||||
quota
|
||||
}
|
||||
|
||||
fn merge_rate_limit_details(target: &mut Map<String, Value>, details: &RateLimitDetails) {
|
||||
fn passive_rate_limit_snapshot(details: &RateLimitDetails) -> Option<Map<String, Value>> {
|
||||
// 响应头的窗口属于同一次上游观测;跨响应合并会制造不存在的额度窗口。
|
||||
let mut snapshot = Map::new();
|
||||
if let Some(allowed) = details.allowed {
|
||||
target.insert("allowed".to_owned(), Value::Bool(allowed));
|
||||
snapshot.insert("allowed".to_owned(), Value::Bool(allowed));
|
||||
}
|
||||
if let Some(limit_reached) = details.limit_reached {
|
||||
target.insert("limit_reached".to_owned(), Value::Bool(limit_reached));
|
||||
snapshot.insert("limit_reached".to_owned(), Value::Bool(limit_reached));
|
||||
}
|
||||
for (field, window) in [
|
||||
("primary_window", details.primary),
|
||||
@@ -1032,28 +1027,29 @@ fn merge_rate_limit_details(target: &mut Map<String, Value>, details: &RateLimit
|
||||
let Some(window) = window else {
|
||||
continue;
|
||||
};
|
||||
let mut value = target
|
||||
.remove(field)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
merge_rate_limit_window(&mut value, window);
|
||||
target.insert(field.to_owned(), Value::Object(value));
|
||||
snapshot.insert(
|
||||
field.to_owned(),
|
||||
Value::Object(passive_rate_limit_window(window)),
|
||||
);
|
||||
}
|
||||
(!snapshot.is_empty()).then_some(snapshot)
|
||||
}
|
||||
|
||||
fn merge_rate_limit_window(target: &mut Map<String, Value>, window: RateLimitWindow) {
|
||||
fn passive_rate_limit_window(window: RateLimitWindow) -> Map<String, Value> {
|
||||
let mut snapshot = Map::new();
|
||||
if let Some(number) = serde_json::Number::from_f64(window.used_percent) {
|
||||
target.insert("used_percent".to_owned(), Value::Number(number));
|
||||
snapshot.insert("used_percent".to_owned(), Value::Number(number));
|
||||
}
|
||||
if let Some(seconds) = window
|
||||
.window_minutes
|
||||
.and_then(|minutes| minutes.checked_mul(60))
|
||||
{
|
||||
target.insert("limit_window_seconds".to_owned(), Value::from(seconds));
|
||||
snapshot.insert("limit_window_seconds".to_owned(), Value::from(seconds));
|
||||
}
|
||||
if let Some(reset_at) = window.reset_at {
|
||||
target.insert("reset_at".to_owned(), Value::from(reset_at));
|
||||
snapshot.insert("reset_at".to_owned(), Value::from(reset_at));
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn quota_success_availability(
|
||||
|
||||
@@ -265,6 +265,129 @@ async fn completion_returns_verified_core_account_without_writing_store() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_accepts_unrelated_oauth_callback_parameters() {
|
||||
let service = service();
|
||||
let started = started(&service).await;
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state");
|
||||
|
||||
let prepared = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect("complete OAuth with standard callback parameters");
|
||||
|
||||
assert!(matches!(
|
||||
prepared.credential,
|
||||
CompletedCodexOAuthCredential::Create(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_rejects_repeated_code_callback_parameter() {
|
||||
let service = service();
|
||||
let started = started(&service).await;
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state");
|
||||
|
||||
let error = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&code=another-code&state={state}"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect_err("repeated code");
|
||||
|
||||
assert_eq!(error, CodexOAuthAdminError::UpstreamRejected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_rejects_repeated_state_callback_parameter() {
|
||||
let service = service();
|
||||
let started = started(&service).await;
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state");
|
||||
|
||||
let error = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&state={state}&state=another-state-value"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect_err("repeated state");
|
||||
|
||||
assert_eq!(error, CodexOAuthAdminError::UpstreamRejected);
|
||||
}
|
||||
|
||||
struct UnavailableVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for UnavailableVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Unavailable)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &SecretString,
|
||||
_: &SecretString,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_classifies_unavailable_identity_verification_as_upstream_unavailable() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let service = service_with_store(store, Arc::new(UnavailableVerifier));
|
||||
let started = started(&service).await;
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state");
|
||||
|
||||
let error = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect_err("identity verification unavailable");
|
||||
|
||||
assert_eq!(error, CodexOAuthAdminError::UpstreamUnavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_state_mismatch_fails_closed_and_consumes_flow() {
|
||||
let service = service();
|
||||
|
||||
@@ -361,6 +361,73 @@ async fn passive_rate_limit_headers_update_quota_and_account_state_with_revision
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passive_rate_limit_headers_replace_stale_secondary_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_passive_quota_snapshot").await;
|
||||
let account = store
|
||||
.account("acct_passive_quota_snapshot")
|
||||
.expect("account");
|
||||
let baseline = json!({
|
||||
"rate_limit": {
|
||||
"primary_window": {
|
||||
"used_percent": 91,
|
||||
"reset_at": 1_900_000_000,
|
||||
"limit_window_seconds": 2_592_000
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 43,
|
||||
"reset_at": 1_900_000_000,
|
||||
"limit_window_seconds": 2_592_000
|
||||
}
|
||||
}
|
||||
});
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
baseline.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
})
|
||||
.await
|
||||
.expect("persist baseline quota");
|
||||
let service = quota_service(&store);
|
||||
let headers = vec![
|
||||
("x-codex-active-limit".to_owned(), "codex".to_owned()),
|
||||
("x-codex-primary-used-percent".to_owned(), "92".to_owned()),
|
||||
(
|
||||
"x-codex-primary-window-minutes".to_owned(),
|
||||
"43200".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-reset-at".to_owned(),
|
||||
"1900000000".to_owned(),
|
||||
),
|
||||
];
|
||||
|
||||
assert!(
|
||||
service
|
||||
.synchronize_passive_headers(&account, &headers)
|
||||
.await
|
||||
.expect("passive quota")
|
||||
);
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read quota")
|
||||
.expect("quota snapshot");
|
||||
let monthly = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.filter(|window| window.kind() == CodexQuotaWindowKind::Monthly)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(monthly.len(), 1);
|
||||
assert_eq!(monthly[0].used_percent(), Some(92.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn synchronize_without_accounts_is_a_noop_before_network_io() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -7,9 +7,7 @@ use std::time::SystemTime;
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use gateway_admin::model::accounts::{
|
||||
AccountAvailability as AdminAccountAvailability, AccountRecord,
|
||||
};
|
||||
use gateway_admin::model::accounts::AccountRecord;
|
||||
use gateway_admin::model::observability::{
|
||||
CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DecimalAmount, DesktopReleaseStatus,
|
||||
@@ -29,9 +27,8 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
use gateway_core::accounting::Money;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability, CredentialRevision, LoadedCredential, NewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore,
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
@@ -773,7 +770,7 @@ fn prepared_create(
|
||||
access_token_expires_at: account.access_token_expires_at().map(DateTime::<Utc>::from),
|
||||
next_refresh_at: account.next_refresh_at().map(Into::into),
|
||||
enabled: account.enabled(),
|
||||
availability: admin_availability(account.availability()),
|
||||
availability: account.availability(),
|
||||
availability_reason: None,
|
||||
cooldown_until: account.cooldown_until().map(Into::into),
|
||||
availability_observed_at: observed_at,
|
||||
@@ -880,7 +877,7 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
|
||||
)
|
||||
.with_runtime_state(
|
||||
account.enabled,
|
||||
core_availability(account.availability),
|
||||
account.availability,
|
||||
account.cooldown_until.map(Into::into),
|
||||
)
|
||||
.with_refresh_schedule(
|
||||
@@ -899,7 +896,7 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
|
||||
&& account.upstream_account_id() == record.upstream_account_id.as_deref()
|
||||
&& account.plan_type() == record.plan_type.as_deref()
|
||||
&& account.enabled() == record.enabled
|
||||
&& admin_availability(account.availability()) == record.availability
|
||||
&& account.availability() == record.availability
|
||||
&& account.cooldown_until().map(DateTime::<Utc>::from) == record.cooldown_until
|
||||
&& account.access_token_expires_at().map(DateTime::<Utc>::from)
|
||||
== record.access_token_expires_at
|
||||
@@ -907,30 +904,6 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
|
||||
&& account.has_refresh_token() == record.has_refresh_token
|
||||
}
|
||||
|
||||
const fn admin_availability(value: AccountAvailability) -> AdminAccountAvailability {
|
||||
match value {
|
||||
AccountAvailability::Unknown => AdminAccountAvailability::Unknown,
|
||||
AccountAvailability::Ready => AdminAccountAvailability::Ready,
|
||||
AccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
|
||||
AccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
|
||||
AccountAvailability::Expired => AdminAccountAvailability::Expired,
|
||||
AccountAvailability::Banned => AdminAccountAvailability::Banned,
|
||||
AccountAvailability::Invalid => AdminAccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
const fn core_availability(value: AdminAccountAvailability) -> AccountAvailability {
|
||||
match value {
|
||||
AdminAccountAvailability::Unknown => AccountAvailability::Unknown,
|
||||
AdminAccountAvailability::Ready => AccountAvailability::Ready,
|
||||
AdminAccountAvailability::Cooldown => AccountAvailability::Cooldown,
|
||||
AdminAccountAvailability::QuotaExhausted => AccountAvailability::QuotaExhausted,
|
||||
AdminAccountAvailability::Expired => AccountAvailability::Expired,
|
||||
AdminAccountAvailability::Banned => AccountAvailability::Banned,
|
||||
AdminAccountAvailability::Invalid => AccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_quota(
|
||||
snapshot: Option<GrokQuotaSnapshot>,
|
||||
refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -6,10 +6,10 @@ use std::time::SystemTime;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
ProviderAccountUpdate, QuotaObservation, QuotaWriteOutcome,
|
||||
AccountStateChange, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
|
||||
LoadedCredential, NewProviderAccount, OpaqueProviderData, PlaintextCredential, ProviderAccount,
|
||||
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, QuotaObservation,
|
||||
QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::provider_ports::ProviderRefreshPolicy;
|
||||
@@ -170,7 +170,7 @@ impl GrokCredentialAdmin {
|
||||
)
|
||||
.with_runtime_state(
|
||||
input.enabled,
|
||||
availability(input.initial_availability),
|
||||
input.initial_availability.into(),
|
||||
input.initial_cooldown_until.map(to_system_time),
|
||||
)
|
||||
.with_refresh_schedule(true, Some(to_system_time(input.next_refresh_at)));
|
||||
@@ -440,7 +440,7 @@ impl GrokCredentialRepository {
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: input.account_id.clone(),
|
||||
expected_revision: input.expected_revision,
|
||||
availability: availability(input.availability),
|
||||
availability: input.availability.into(),
|
||||
reason: input.availability_reason.clone(),
|
||||
cooldown_until: input.cooldown_until.map(to_system_time),
|
||||
observed_at: to_system_time(input.observed_at),
|
||||
@@ -839,18 +839,6 @@ fn validate_cooldown(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn availability(value: GrokCredentialAvailability) -> AccountAvailability {
|
||||
match value {
|
||||
GrokCredentialAvailability::Unknown => AccountAvailability::Unknown,
|
||||
GrokCredentialAvailability::Ready => AccountAvailability::Ready,
|
||||
GrokCredentialAvailability::Cooldown => AccountAvailability::Cooldown,
|
||||
GrokCredentialAvailability::QuotaExhausted => AccountAvailability::QuotaExhausted,
|
||||
GrokCredentialAvailability::Expired => AccountAvailability::Expired,
|
||||
GrokCredentialAvailability::Banned => AccountAvailability::Banned,
|
||||
GrokCredentialAvailability::Invalid => AccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_xai(account: &ProviderAccount) -> Result<(), GrokCredentialRepositoryError> {
|
||||
if account.provider().as_str() == XAI_PROVIDER_KIND
|
||||
&& account.authentication_kind() == XAI_AUTHENTICATION_KIND_OAUTH
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialCasUpdate, CredentialRevision, LoadedCredential, ProviderAccountId,
|
||||
ProviderAccountUpdate,
|
||||
AccountAvailability, CredentialCasUpdate, CredentialRevision, LoadedCredential,
|
||||
ProviderAccountId, ProviderAccountUpdate,
|
||||
};
|
||||
use gateway_core::provider_ports::ProviderLeaseGuard;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -75,6 +75,21 @@ pub enum GrokCredentialAvailability {
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Grok 可用性语义翻入内核标准态的唯一汇入点。
|
||||
impl From<GrokCredentialAvailability> for AccountAvailability {
|
||||
fn from(value: GrokCredentialAvailability) -> Self {
|
||||
match value {
|
||||
GrokCredentialAvailability::Unknown => Self::Unknown,
|
||||
GrokCredentialAvailability::Ready => Self::Ready,
|
||||
GrokCredentialAvailability::Cooldown => Self::Cooldown,
|
||||
GrokCredentialAvailability::QuotaExhausted => Self::QuotaExhausted,
|
||||
GrokCredentialAvailability::Expired => Self::Expired,
|
||||
GrokCredentialAvailability::Banned => Self::Banned,
|
||||
GrokCredentialAvailability::Invalid => Self::Invalid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建一个明文 OAuth Provider account。
|
||||
pub struct CreateGrokCredential {
|
||||
pub account_id: ProviderAccountId,
|
||||
|
||||
@@ -6,9 +6,7 @@ use std::time::{Duration, SystemTime};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::{StreamExt as _, future::BoxFuture};
|
||||
use gateway_admin::model::accounts::{
|
||||
AccountAvailability as AdminAccountAvailability, AccountRecord,
|
||||
};
|
||||
use gateway_admin::model::accounts::AccountRecord;
|
||||
use gateway_admin::model::observability::{
|
||||
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
|
||||
};
|
||||
@@ -620,7 +618,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
|
||||
access_token_expires_at: account.access_token_expires_at().map(DateTime::<Utc>::from),
|
||||
next_refresh_at: account.next_refresh_at().map(DateTime::<Utc>::from),
|
||||
enabled: account.enabled(),
|
||||
availability: admin_availability(account.availability()),
|
||||
availability: account.availability(),
|
||||
availability_reason: None,
|
||||
cooldown_until: account.cooldown_until().map(DateTime::<Utc>::from),
|
||||
availability_observed_at: now,
|
||||
@@ -630,18 +628,6 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
|
||||
}
|
||||
}
|
||||
|
||||
const fn admin_availability(value: AccountAvailability) -> AdminAccountAvailability {
|
||||
match value {
|
||||
AccountAvailability::Unknown => AdminAccountAvailability::Unknown,
|
||||
AccountAvailability::Ready => AdminAccountAvailability::Ready,
|
||||
AccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
|
||||
AccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
|
||||
AccountAvailability::Expired => AdminAccountAvailability::Expired,
|
||||
AccountAvailability::Banned => AdminAccountAvailability::Banned,
|
||||
AccountAvailability::Invalid => AdminAccountAvailability::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
struct TestLeases;
|
||||
|
||||
impl ProviderLeasePort for TestLeases {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
quotaWindowPercentTextClass,
|
||||
} from '../constants'
|
||||
|
||||
type UsageWindowVariant = 'compact' | 'detail'
|
||||
type UsageWindowVariant = 'compact' | 'detail' | 'metric'
|
||||
type UsageWindowMode = 'quota' | 'local' | 'unknown'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -24,6 +24,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const isCompact = computed(() => props.variant === 'compact')
|
||||
const isMetric = computed(() => props.variant === 'metric')
|
||||
const localUsage = computed<AccountLocalUsage | null>(() => {
|
||||
const value = props.window?.localUsage
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
@@ -55,32 +56,56 @@ const quotaLocalUsageVisible = computed(() =>
|
||||
&& localUsage.value.totalTokens > 0,
|
||||
)
|
||||
|
||||
function variantValue(metric: string, compact: string, detail: string) {
|
||||
if (isMetric.value)
|
||||
return metric
|
||||
if (isCompact.value)
|
||||
return compact
|
||||
return detail
|
||||
}
|
||||
|
||||
const rootClass = computed(() =>
|
||||
isCompact.value ? 'flex min-w-0 flex-col' : 'rounded-lg bg-(--cp-bg-subtle) p-2',
|
||||
variantValue(
|
||||
'grid min-w-0 grid-rows-[11px_14px] gap-1',
|
||||
'flex min-w-0 flex-col',
|
||||
'rounded-lg bg-(--cp-bg-subtle) p-2',
|
||||
),
|
||||
)
|
||||
const headerClass = computed(() =>
|
||||
isCompact.value
|
||||
? 'mb-1 flex items-center justify-between gap-2 text-[11px] leading-none font-bold'
|
||||
: 'flex items-center justify-between gap-3 text-[12px] font-bold',
|
||||
variantValue(
|
||||
'flex items-center justify-between gap-2 text-[11px] leading-none font-bold',
|
||||
'mb-1 flex items-center justify-between gap-2 text-[11px] leading-none font-bold',
|
||||
'flex items-center justify-between gap-3 text-[12px] font-bold',
|
||||
),
|
||||
)
|
||||
const labelClass = computed(() =>
|
||||
isCompact.value ? 'text-(--cp-text-muted)' : 'text-(--cp-text-secondary)',
|
||||
isCompact.value || isMetric.value ? 'text-(--cp-text-muted)' : 'text-(--cp-text-secondary)',
|
||||
)
|
||||
const valueClass = computed(() =>
|
||||
isCompact.value
|
||||
? 'text-[10px] leading-none font-heavy'
|
||||
: 'text-[12px] font-heavy',
|
||||
variantValue(
|
||||
'text-[11px] leading-none font-bold',
|
||||
'text-[10px] leading-none font-heavy',
|
||||
'text-[12px] font-heavy',
|
||||
),
|
||||
)
|
||||
const trackOffsetClass = computed(() =>
|
||||
variantValue('self-center', 'mt-auto', 'mt-2'),
|
||||
)
|
||||
const trackShapeClass = computed(() =>
|
||||
isCompact.value
|
||||
? 'h-1 w-full overflow-hidden rounded-full'
|
||||
: 'h-2 overflow-hidden rounded-full',
|
||||
variantValue(
|
||||
'h-1.5 w-full overflow-hidden rounded-full',
|
||||
'h-1 w-full overflow-hidden rounded-full',
|
||||
'h-2 overflow-hidden rounded-full',
|
||||
),
|
||||
)
|
||||
const trackClass = computed(() => `${trackShapeClass.value} bg-(--cp-default-border)`)
|
||||
const barStyle = computed(() => {
|
||||
if (!props.window)
|
||||
return undefined
|
||||
return quotaWindowBarStyle(props.window, isCompact.value ? '4px' : '8px')
|
||||
return quotaWindowBarStyle(
|
||||
props.window,
|
||||
variantValue('6px', '4px', '8px'),
|
||||
)
|
||||
})
|
||||
const barClass = computed(() => props.window ? quotaWindowBarClass(props.window) : undefined)
|
||||
const percentTextClass = computed(() =>
|
||||
@@ -162,7 +187,7 @@ const requestBars = computed(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[trackClass, !isCompact ? 'mt-2' : 'mt-auto']"
|
||||
:class="[trackClass, trackOffsetClass]"
|
||||
role="progressbar"
|
||||
:aria-label="window.labelDisplay"
|
||||
aria-valuemin="0"
|
||||
@@ -198,7 +223,7 @@ const requestBars = computed(() => {
|
||||
</div>
|
||||
<div
|
||||
class="flex items-stretch gap-px"
|
||||
:class="[trackShapeClass, !isCompact ? 'mt-2' : 'mt-auto']"
|
||||
:class="[trackShapeClass, trackOffsetClass]"
|
||||
role="img"
|
||||
:aria-label="requestTimelineTitle"
|
||||
:title="requestTimelineTitle"
|
||||
|
||||
@@ -272,7 +272,7 @@ const statusBars = computed(() => {
|
||||
<AccountUsageWindow
|
||||
:window="account.usageWindow"
|
||||
:show-local-value="false"
|
||||
variant="compact"
|
||||
variant="metric"
|
||||
/>
|
||||
</span>
|
||||
</article>
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.0.14"
|
||||
version: "3.0.16"
|
||||
|
||||
Reference in New Issue
Block a user