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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-01 13:22:20 +08:00
co-authored by Codex
parent 9c10676764
commit 0490e28cfc
22 changed files with 249 additions and 8 deletions
@@ -826,11 +826,20 @@ pub struct ProviderQuota {
pub observed_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
pub credits: Option<ProviderQuotaCredits>,
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
pub limit_reached: bool,
pub provider_data: Option<ProviderDocument>,
}
/// 上游点数余额的展示事实,保留原始十进制文本以避免精度损失。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderQuotaCredits {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
value.filter(|value| {
@@ -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,
@@ -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,
@@ -84,6 +84,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> 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,
@@ -56,6 +56,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
credits: None,
plan_type: None,
observed_at: Some(now()),
refresh_token_expires_at: None,
@@ -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,
@@ -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,
@@ -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,
)
@@ -397,6 +397,16 @@ pub struct AccountQuotaView {
pub rate_limit_reason: Option<String>,
pub recovery_probe_required: bool,
pub windows: Vec<AccountQuotaWindowView>,
pub credits: Option<AccountQuotaCreditsView>,
}
/// 上游点数安全视图,不透出额度响应中的其他 Provider 字段。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaCreditsView {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
/// 一个 quota 时间窗口。
@@ -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,
@@ -766,7 +766,8 @@ fn parse_credits_from_lookup(headers: &BTreeMap<String, &str>) -> Option<Credits
})
}
fn parse_credits_from_object(value: &Value) -> Option<CreditsSnapshot> {
/// 从额度查询或流式事件中提取同一份点数事实,缺失字段不推断为零余额。
pub fn parse_credits_from_object(value: &Value) -> Option<CreditsSnapshot> {
Some(CreditsSnapshot {
has_credits: value.get("has_credits")?.as_bool()?,
unlimited: value.get("unlimited")?.as_bool()?,
+9 -3
View File
@@ -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::<Utc>::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,
@@ -1949,6 +1949,9 @@ fn merge_passive_metadata(quota: &mut Map<String, Value>, 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));
}
@@ -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<String>,
credits: Option<CreditsSnapshot>,
fact: CodexQuotaFact,
quota: QuotaState,
windows: Vec<CodexQuotaWindow>,
@@ -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,
@@ -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());
@@ -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);
}
}
@@ -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,
+5
View File
@@ -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
字符,允许换行和制表符,保存时去除首尾空白,空字符串清空备注。备注独立于上游身份,导入时未显式提供备注、
重新授权、凭据刷新及批量调度更新均保留已有备注
+7
View File
@@ -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 {
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { AccountQuotaCredits } from '@/api'
import { computed } from 'vue'
const props = defineProps<{
credits: AccountQuotaCredits | null
}>()
const integerFormatter = new Intl.NumberFormat('zh-CN')
const balanceDisplay = computed(() => {
const balance = props.credits?.balance?.trim()
const decimal = balance?.match(/^(-?)(\d+)(?:\.(\d+))?$/)
if (!decimal)
return null
const [, sign, integer, fraction] = decimal
// 点数可能包含高精度小数,整数分组与小数文本分别处理,避免转成浮点数。
const grouped = `${sign}${integerFormatter.format(BigInt(integer!))}`
return fraction ? `${grouped}.${fraction}` : grouped
})
const value = computed(() => {
if (props.credits?.unlimited)
return '无限'
if (balanceDisplay.value !== null)
return balanceDisplay.value
if (props.credits && !props.credits.hasCredits)
return '暂无可用点数'
return '未提供余额'
})
const hasBalance = computed(() => !props.credits?.unlimited && balanceDisplay.value !== null)
</script>
<template>
<div class="flex min-w-0 flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-cp bg-cp-fill-quaternary px-4 py-3.5" aria-label="额度点数">
<span class="text-cp-sm font-heavy text-cp-text">额度点数</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<strong
class="min-w-0 text-right text-cp-sm font-heavy wrap-anywhere"
:class="hasBalance ? 'font-mono tabular-nums text-cp-text' : 'text-cp-text-secondary'"
>
{{ value }}
</strong>
<span v-if="hasBalance" class="shrink-0 text-cp-xs text-cp-text-tertiary">点</span>
</span>
</div>
</template>
@@ -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"
>
<TicketCheck class="size-4 shrink-0" />
<TicketCheck class="size-3.5 shrink-0" />
<span v-if="showTriggerCount" class="translate-y-px font-mono text-[10px] leading-none font-heavy tabular-nums">
x{{ availableCount }}
</span>
@@ -247,6 +248,8 @@ function handleRequestConsume(creditId: string) {
/>
</div>
</section>
<AccountQuotaCredits :credits="account.quota.credits" />
</div>
<template v-if="showConfirm || showCountOnlyAction" #footer>
@@ -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"
>
<span>{{ formatProviderLabel(account.provider) }} 额度</span>
<span>{{ account.provider === 'openai' ? 'Codex' : formatProviderLabel(account.provider) }} 额度</span>
<template v-if="account.planType">
<span>·</span>
<AccountPlanBadge :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" />
@@ -57,7 +57,7 @@ watch(hasPersonalInfo, (available) => {
<span>最近刷新: {{ account.quota.refreshedAtDisplay }}</span>
</p>
</div>
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5">
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5 [&_svg]:size-3.5 [&_svg]:stroke-2">
<BaseIconButton
v-if="hasPersonalInfo"
label="查看个人信息"
@@ -82,7 +82,7 @@ watch(hasPersonalInfo, (available) => {
:pressed="forecastOpen"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
<ChartNoAxesCombined class="size-3.5" />
</BaseIconButton>
<BaseIconButton
v-if="account.capabilities.quotaRefresh"