From 0490e28cfc14f08232a84c52e8c3f8693285b994 Mon Sep 17 00:00:00 2001
From: Zyy
Date: Thu, 1 Oct 2026 13:22:20 +0800
Subject: [PATCH] feat(accounts): show Codex credit balances in quota reset
dialog
Expose upstream credit facts through the account quota API while preserving
decimal balances and distinguishing zero, unlimited, and missing values.
Clear stale balances on passive updates without changing quota availability.
Display credits below reset cards, label OpenAI quota as Codex quota, and
standardize the account quota action icons.
Co-authored-by: Codex
---
.../src/model/provider_credentials.rs | 9 +++
.../gateway-admin/src/use_case/accounts.rs | 1 +
backend/crates/gateway-admin/tests/main.rs | 5 ++
.../tests/model/provider_credentials.rs | 2 +
.../tests/model/quota_forecast.rs | 1 +
.../gateway-admin/tests/use_case/accounts.rs | 9 +++
.../gateway-admin/tests/use_case/proxies.rs | 1 +
.../src/admin/accounts/presenter.rs | 5 ++
.../gateway-api/src/admin/accounts/wire.rs | 10 +++
.../runtime/tests/generation/data.rs | 1 +
.../gateway-protocol/src/openai/events.rs | 3 +-
backend/crates/providers/openai/src/admin.rs | 12 ++-
.../openai/src/credential/quota/mod.rs | 3 +
.../openai/src/credential/quota/snapshot.rs | 9 +++
.../crates/providers/openai/tests/admin.rs | 79 +++++++++++++++++++
.../openai/tests/credential/quota/mod.rs | 37 +++++++++
backend/crates/providers/xai/src/admin.rs | 2 +
docs/api.md | 5 ++
frontend/src/api/modules/accounts.ts | 7 ++
.../components/AccountQuotaPanel/Credits.vue | 45 +++++++++++
.../AccountQuotaPanel/ResetCredits.vue | 5 +-
.../components/AccountQuotaPanel/index.vue | 6 +-
22 files changed, 249 insertions(+), 8 deletions(-)
create mode 100644 frontend/src/views/accounts/components/AccountQuotaPanel/Credits.vue
diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs
index 0c25c44ed..a7fa1c039 100644
--- a/backend/crates/gateway-admin/src/model/provider_credentials.rs
+++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs
@@ -826,11 +826,20 @@ pub struct ProviderQuota {
pub observed_at: Option>,
pub refresh_token_expires_at: Option>,
pub windows: Vec,
+ pub credits: Option,
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
pub limit_reached: bool,
pub provider_data: Option,
}
+/// 上游点数余额的展示事实,保留原始十进制文本以避免精度损失。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProviderQuotaCredits {
+ pub has_credits: bool,
+ pub unlimited: bool,
+ pub balance: Option,
+}
+
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
value.filter(|value| {
diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs
index 5830222ee..0daafe833 100644
--- a/backend/crates/gateway-admin/src/use_case/accounts.rs
+++ b/backend/crates/gateway-admin/src/use_case/accounts.rs
@@ -1052,6 +1052,7 @@ fn map_reset_credits_error_after_refresh(
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
fn empty_quota() -> ProviderQuota {
ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
diff --git a/backend/crates/gateway-admin/tests/main.rs b/backend/crates/gateway-admin/tests/main.rs
index 7743852bc..448160f15 100644
--- a/backend/crates/gateway-admin/tests/main.rs
+++ b/backend/crates/gateway-admin/tests/main.rs
@@ -129,6 +129,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
#[test]
fn representative_quota_should_prefer_short_window_and_highest_usage() {
let quota = ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -150,6 +151,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -187,6 +189,7 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
models: Vec::new(),
};
let quota = ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -215,6 +218,7 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -243,6 +247,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
diff --git a/backend/crates/gateway-admin/tests/model/provider_credentials.rs b/backend/crates/gateway-admin/tests/model/provider_credentials.rs
index 291fd9640..5b2cdac4a 100644
--- a/backend/crates/gateway-admin/tests/model/provider_credentials.rs
+++ b/backend/crates/gateway-admin/tests/model/provider_credentials.rs
@@ -84,6 +84,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
fn usage_quota(windows: Vec) -> ProviderQuota {
ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -133,6 +134,7 @@ fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast.rs b/backend/crates/gateway-admin/tests/model/quota_forecast.rs
index d54fd20b2..945816f27 100644
--- a/backend/crates/gateway-admin/tests/model/quota_forecast.rs
+++ b/backend/crates/gateway-admin/tests/model/quota_forecast.rs
@@ -56,6 +56,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
fn quota(windows: Vec) -> ProviderQuota {
ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: Some(now()),
refresh_token_expires_at: None,
diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs
index de65d53c2..c716fc2c0 100644
--- a/backend/crates/gateway-admin/tests/use_case/accounts.rs
+++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs
@@ -1655,6 +1655,7 @@ async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_qu
);
}
let quota = ProviderQuota {
+ credits: None,
observed_at: Some(Utc::now()),
windows: vec![ProviderQuotaWindow {
key: "primary".to_owned(),
@@ -1993,6 +1994,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
let events = events();
let openai = FakeProviderAdmin::new("openai", events.clone());
openai.set_quota(ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2125,6 +2127,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2173,6 +2176,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2224,6 +2228,7 @@ async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time(
let observed = now - TimeDelta::hours(1);
let reset = now + TimeDelta::days(1);
provider.set_quota(ProviderQuota {
+ credits: None,
observed_at: Some(observed),
limit_reached: true,
windows: vec![ProviderQuotaWindow {
@@ -2378,6 +2383,7 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
let added = now - TimeDelta::hours(5);
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
+ credits: None,
plan_type: Some("pro".to_owned()),
observed_at: Some(observed),
windows: vec![ProviderQuotaWindow {
@@ -2524,6 +2530,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::hours(1);
provider.set_quota(ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2681,6 +2688,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::days(7);
provider.set_quota(ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
@@ -2982,6 +2990,7 @@ pub(super) fn document() -> ProviderDocument {
fn empty_quota() -> ProviderQuota {
ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
diff --git a/backend/crates/gateway-admin/tests/use_case/proxies.rs b/backend/crates/gateway-admin/tests/use_case/proxies.rs
index b7de1f0f2..399ebb8c4 100644
--- a/backend/crates/gateway-admin/tests/use_case/proxies.rs
+++ b/backend/crates/gateway-admin/tests/use_case/proxies.rs
@@ -190,6 +190,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
+ credits: None,
plan_type: cached.map(str::to_owned),
observed_at: None,
refresh_token_expires_at: None,
diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs
index c816eeb01..752a3852c 100644
--- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs
+++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs
@@ -220,6 +220,11 @@ pub(super) fn account_quota_view(
rate_limit_reason,
recovery_probe_required,
windows,
+ credits: quota.credits.map(|credits| AccountQuotaCreditsView {
+ has_credits: credits.has_credits,
+ unlimited: credits.unlimited,
+ balance: credits.balance,
+ }),
},
refresh_token_expires_at,
)
diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs
index 937c2c6fc..4d10f29b1 100644
--- a/backend/crates/gateway-api/src/admin/accounts/wire.rs
+++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs
@@ -397,6 +397,16 @@ pub struct AccountQuotaView {
pub rate_limit_reason: Option,
pub recovery_probe_required: bool,
pub windows: Vec,
+ pub credits: Option,
+}
+
+/// 上游点数安全视图,不透出额度响应中的其他 Provider 字段。
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AccountQuotaCreditsView {
+ pub has_credits: bool,
+ pub unlimited: bool,
+ pub balance: Option,
}
/// 一个 quota 时间窗口。
diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs
index 777a64455..81eec737c 100644
--- a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs
+++ b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs
@@ -115,6 +115,7 @@ impl PluginAccountAccess for Facts {
self.0.fetch_add(1, Ordering::SeqCst);
assert_eq!(account.as_str(), "acct_facts");
Ok(ProviderQuota {
+ credits: None,
plan_type: Some("private plan".into()),
observed_at: None,
refresh_token_expires_at: None,
diff --git a/backend/crates/gateway-protocol/src/openai/events.rs b/backend/crates/gateway-protocol/src/openai/events.rs
index 7384dfed2..6cccaec87 100644
--- a/backend/crates/gateway-protocol/src/openai/events.rs
+++ b/backend/crates/gateway-protocol/src/openai/events.rs
@@ -766,7 +766,8 @@ fn parse_credits_from_lookup(headers: &BTreeMap) -> Option Option {
+/// 从额度查询或流式事件中提取同一份点数事实,缺失字段不推断为零余额。
+pub fn parse_credits_from_object(value: &Value) -> Option {
Some(CreditsSnapshot {
has_credits: value.get("has_credits")?.as_bool()?,
unlimited: value.get("unlimited")?.as_bool()?,
diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs
index e858eb301..34a7714ed 100644
--- a/backend/crates/providers/openai/src/admin.rs
+++ b/backend/crates/providers/openai/src/admin.rs
@@ -24,9 +24,9 @@ use gateway_admin::model::provider_credentials::{
ProviderExportCredentialInput, ProviderModel, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileAvatarStreamError,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
- ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
- ProviderQuotaWindowRole, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
- ProviderSubscription, QuotaLocalUsageAttribution,
+ ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaCredits, ProviderQuotaRequest,
+ ProviderQuotaWindow, ProviderQuotaWindowRole, ProviderResetCredit, ProviderResetCreditResult,
+ ProviderResetCredits, ProviderSubscription, QuotaLocalUsageAttribution,
};
use gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation;
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -1109,6 +1109,7 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
fn empty_quota() -> ProviderQuota {
ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
@@ -1174,6 +1175,11 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
observed_at: Some(DateTime::::from(snapshot.observed_at())),
refresh_token_expires_at: None,
windows,
+ credits: snapshot.credits().map(|credits| ProviderQuotaCredits {
+ has_credits: credits.has_credits,
+ unlimited: credits.unlimited,
+ balance: credits.balance.clone(),
+ }),
limit_reached,
provider_data: Some(ProviderDocument::new(OpaqueProviderData::new(
provider_data,
diff --git a/backend/crates/providers/openai/src/credential/quota/mod.rs b/backend/crates/providers/openai/src/credential/quota/mod.rs
index 5b9b9b228..ad934a9d0 100644
--- a/backend/crates/providers/openai/src/credential/quota/mod.rs
+++ b/backend/crates/providers/openai/src/credential/quota/mod.rs
@@ -1949,6 +1949,9 @@ fn merge_passive_metadata(quota: &mut Map, rate_limits: &ParsedRa
value.insert("unlimited".to_owned(), Value::Bool(credits.unlimited));
if let Some(balance) = credits.balance.as_ref() {
value.insert("balance".to_owned(), Value::String(balance.clone()));
+ } else {
+ // 点数对象明确更新但未提供余额时,不能把旧余额继续作为当前余额展示。
+ value.remove("balance");
}
quota.insert("credits".to_owned(), Value::Object(value));
}
diff --git a/backend/crates/providers/openai/src/credential/quota/snapshot.rs b/backend/crates/providers/openai/src/credential/quota/snapshot.rs
index 3ae020263..468f97300 100644
--- a/backend/crates/providers/openai/src/credential/quota/snapshot.rs
+++ b/backend/crates/providers/openai/src/credential/quota/snapshot.rs
@@ -10,6 +10,7 @@ use gateway_core::account::{
AccountQuotaSignals, CredentialRevision, ProviderAccountId, QuotaEvidence, QuotaObservation,
QuotaState,
};
+use gateway_protocol::openai::events::{CreditsSnapshot, parse_credits_from_object};
use serde_json::{Map, Value};
use super::document::{
@@ -114,6 +115,7 @@ pub struct CodexAccountQuotaSnapshot {
credential_revision: CredentialRevision,
observed_at: SystemTime,
plan_type: Option,
+ credits: Option,
fact: CodexQuotaFact,
quota: QuotaState,
windows: Vec,
@@ -142,6 +144,12 @@ impl CodexAccountQuotaSnapshot {
self.plan_type.as_deref()
}
+ /// 点数只用于展示,不改变套餐限额或账号可用性。
+ #[must_use]
+ pub fn credits(&self) -> Option<&CreditsSnapshot> {
+ self.credits.as_ref()
+ }
+
#[must_use]
pub const fn fact(&self) -> CodexQuotaFact {
self.fact
@@ -350,6 +358,7 @@ pub(crate) fn parse_account_quota_snapshot(
.get("plan_type")
.and_then(Value::as_str)
.map(str::to_owned),
+ credits: object.get("credits").and_then(parse_credits_from_object),
fact,
quota,
windows,
diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs
index 9478b6239..929f6af19 100644
--- a/backend/crates/providers/openai/tests/admin.rs
+++ b/backend/crates/providers/openai/tests/admin.rs
@@ -889,6 +889,85 @@ async fn openai_admin_quota_refresh_updates_the_account_plan() {
}
}
+#[tokio::test]
+async fn openai_admin_quota_projects_credit_balance_from_refresh_and_cached_observation() {
+ let store = Arc::new(MemoryAccountStore::default());
+ store
+ .seed_oauth_credential(ImportCodexOAuthCredential {
+ account_id: "acct_credit_balance".to_owned(),
+ name: "credit balance".to_owned(),
+ secret: secret("credit-balance-test-token"),
+ verified_account: profile("chatgpt-credit-balance"),
+ next_refresh_at: None,
+ enabled: true,
+ })
+ .await;
+ let account = store.account("acct_credit_balance").unwrap();
+ let server = MockServer::start().await;
+ let mut config = valid_config();
+ config.config.api.base_url = server.uri();
+ let bundle = provider_openai::initialize(
+ config.config,
+ provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())),
+ )
+ .await
+ .unwrap();
+ for (wire, expected) in [
+ (
+ json!({"has_credits": true, "unlimited": false, "balance": "62500"}),
+ Some((true, false, Some("62500"))),
+ ),
+ (
+ json!({"has_credits": false, "unlimited": false, "balance": 0}),
+ Some((false, false, Some("0"))),
+ ),
+ (
+ json!({"has_credits": true, "unlimited": false, "balance": "9007199254740993.1234567890"}),
+ Some((true, false, Some("9007199254740993.1234567890"))),
+ ),
+ (
+ json!({"has_credits": true, "unlimited": false, "balance": null}),
+ Some((true, false, None)),
+ ),
+ (
+ json!({"has_credits": false, "unlimited": true}),
+ Some((false, true, None)),
+ ),
+ (Value::Null, None),
+ ] {
+ let _mock = Mock::given(method("GET"))
+ .and(path("/api/codex/usage"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
+ "rate_limit": {"allowed": true, "primary_window": {"used_percent": 28}},
+ "credits": wire,
+ })))
+ .expect(1)
+ .mount_as_scoped(&server)
+ .await;
+ for refresh in [true, false] {
+ let quota = bundle
+ .admin_provider()
+ .quota(ProviderQuotaRequest {
+ account_id: account.id().clone(),
+ refresh,
+ rolling_usage: None,
+ })
+ .await
+ .expect("quota with credits");
+ assert_eq!(
+ quota.credits.as_ref().map(|credits| (
+ credits.has_credits,
+ credits.unlimited,
+ credits.balance.as_deref(),
+ )),
+ expected
+ );
+ assert_eq!(quota.windows[0].used_percent, Some(28.0));
+ assert!(!quota.limit_reached);
+ }
+ }
+}
+
#[tokio::test]
async fn openai_admin_projects_free_plan_from_cached_quota_when_account_claims_omit_it() {
let store = Arc::new(MemoryAccountStore::default());
diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs
index 4255952de..e7a4f5088 100644
--- a/backend/crates/providers/openai/tests/credential/quota/mod.rs
+++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs
@@ -791,3 +791,40 @@ async fn quota_refresh_preserves_disabled_and_credential_error_facts() {
AccountStatus::Disabled
);
}
+
+#[tokio::test]
+async fn passive_credit_updates_replace_balance_without_changing_quota_access() {
+ let store = Arc::new(MemoryAccountStore::default());
+ create_account(&store, "acct_passive_credits").await;
+ let account = store.account("acct_passive_credits").unwrap();
+ let service = quota_service(&store);
+ let initial = parse_rate_limits_event(&json!({
+ "type": "codex.rate_limits",
+ "rate_limits": {"primary": {"used_percent": 28, "window_minutes": 300}},
+ "credits": {"has_credits": true, "unlimited": false, "balance": "62500"}
+ }))
+ .unwrap();
+ service
+ .synchronize_passive_rate_limits(&account, &[initial])
+ .await
+ .unwrap();
+ for (wire, expected) in [
+ (
+ json!({"has_credits": false, "unlimited": false, "balance": "0"}),
+ Some("0"),
+ ),
+ (json!({"has_credits": true, "unlimited": false}), None),
+ ] {
+ let update =
+ parse_rate_limits_event(&json!({"type": "codex.rate_limits", "credits": wire}))
+ .unwrap();
+ service
+ .synchronize_passive_rate_limits(&account, &[update])
+ .await
+ .unwrap();
+ let snapshot = service.read_account(account.id()).await.unwrap().unwrap();
+ assert_eq!(snapshot.credits().unwrap().balance.as_deref(), expected);
+ assert_eq!(snapshot.windows()[0].used_percent(), Some(28.0));
+ assert_eq!(snapshot.quota().access(), QuotaAccessState::Allowed);
+ }
+}
diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs
index 3fbb27a6c..61421c6f7 100644
--- a/backend/crates/providers/xai/src/admin.rs
+++ b/backend/crates/providers/xai/src/admin.rs
@@ -1153,6 +1153,7 @@ fn project_quota(
) -> ProviderQuota {
let Some(snapshot) = snapshot else {
return ProviderQuota {
+ credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at,
@@ -1225,6 +1226,7 @@ fn project_quota(
}
};
ProviderQuota {
+ credits: None,
plan_type: billing.plan_type().map(str::to_owned),
observed_at: Some(snapshot.observed_at()),
refresh_token_expires_at,
diff --git a/docs/api.md b/docs/api.md
index 702fdd72a..da6e69918 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -556,6 +556,11 @@ Codex 的 `model_catalog_json` 配置。账号设置保存不等待上游模型
此时 `rateLimitedUntil` 是最早探测时间,到期后仍保持 `rate_limited`,直到探测成功或手动恢复。
未要求探测时,该字段表示冷却结束时间。所有此类情况统一显示“限流中”,仅详情原因和恢复条件不同
+`quota.credits` 返回上游点数余额,未提供点数信息时为 `null`。对象包含 `hasCredits`、`unlimited` 和
+`balance`,余额为保留上游精度的十进制字符串,未提供余额时为 `null`,明确的零余额为 `"0"`。
+OpenAI 点数随现有额度刷新和正常请求的额度信息同步,不与主动重置卡库存或百分比限额合并,
+不参与账号可用性判断
+
账号列表和详情返回 `notes`(无备注时为 `null`)。编辑时省略或 `null` 保留原备注;字符串最多 500 个 Unicode
字符,允许换行和制表符,保存时去除首尾空白,空字符串清空备注。备注独立于上游身份,导入时未显式提供备注、
重新授权、凭据刷新及批量调度更新均保留已有备注
diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts
index 0935df689..e6cdbf82b 100644
--- a/frontend/src/api/modules/accounts.ts
+++ b/frontend/src/api/modules/accounts.ts
@@ -28,6 +28,12 @@ export interface AccountQuotaWindow {
resetAtDisplay: string
}
+export interface AccountQuotaCredits {
+ hasCredits: boolean
+ unlimited: boolean
+ balance: string | null
+}
+
export interface AccountQuota {
refreshedAtDisplay: string
limitReached: boolean
@@ -36,6 +42,7 @@ export interface AccountQuota {
rateLimitReason: 'upstream_rate_limit' | 'capacity_freeze' | null
recoveryProbeRequired: boolean
windows: AccountQuotaWindow[]
+ credits: AccountQuotaCredits | null
}
export interface AccountCurrencyCost {
diff --git a/frontend/src/views/accounts/components/AccountQuotaPanel/Credits.vue b/frontend/src/views/accounts/components/AccountQuotaPanel/Credits.vue
new file mode 100644
index 000000000..fe4c81078
--- /dev/null
+++ b/frontend/src/views/accounts/components/AccountQuotaPanel/Credits.vue
@@ -0,0 +1,45 @@
+
+
+
+
+ 额度点数
+
+
+ {{ value }}
+
+ 点
+
+
+
diff --git a/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue b/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue
index d124a5292..292e4e853 100644
--- a/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue
+++ b/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue
@@ -7,6 +7,7 @@ import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { computed, shallowRef, watch } from 'vue'
import { useAccountResetCredits } from '../../composables/useAccountResetCredits'
+import AccountQuotaCredits from './Credits.vue'
import UsageLimits from './UsageLimits.vue'
const props = defineProps<{
@@ -118,7 +119,7 @@ function handleRequestConsume(creditId: string) {
:title="triggerLabel"
@click="panelOpen = true"
>
-
+
x{{ availableCount }}
@@ -247,6 +248,8 @@ function handleRequestConsume(creditId: string) {
/>
+
+
diff --git a/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue b/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue
index d41089e56..819ea598e 100644
--- a/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue
+++ b/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue
@@ -48,7 +48,7 @@ watch(hasPersonalInfo, (available) => {
v-if="account.capabilities.quota || quotaEntries.length > 0"
class="m-0 mt-1 flex min-w-0 items-center gap-1.5 text-cp-xs font-emphasis text-cp-text-secondary"
>
- {{ formatProviderLabel(account.provider) }} 额度
+ {{ account.provider === 'openai' ? 'Codex' : formatProviderLabel(account.provider) }} 额度
·
@@ -57,7 +57,7 @@ watch(hasPersonalInfo, (available) => {
最近刷新: {{ account.quota.refreshedAtDisplay }}
-
+
{
:pressed="forecastOpen"
@click="forecastOpen = true"
>
-
+