mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(accounts): add optional weekly quota limit for reset credits
Skip new automatic redemptions above the configured remaining weekly quota limit, leaving it unset by default and preserving pending retries. Keep temporarily ineligible credits scheduled and expose the setting beside the redemption lead time. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -76,6 +76,7 @@ pub struct RuntimeSettingsValues {
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
|
||||
/// 完整运行设置事实
|
||||
@@ -193,6 +194,12 @@ impl RuntimeSettingsValues {
|
||||
if !(1..=1440).contains(&self.reset_credit_lead_minutes) {
|
||||
return Err("reset_credit_lead_minutes");
|
||||
}
|
||||
if self
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.is_some_and(|percent| percent > 100)
|
||||
{
|
||||
return Err("reset_credit_max_weekly_remaining_percent");
|
||||
}
|
||||
self.request_location
|
||||
.validate()
|
||||
.map_err(|_| "request_location")?;
|
||||
|
||||
@@ -59,14 +59,15 @@ use chrono::{DateTime, Utc};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
|
||||
impl DefaultAccountsService {
|
||||
async fn automation_lead_minutes(&self) -> Result<u32, AdminError> {
|
||||
async fn automation_settings(
|
||||
&self,
|
||||
) -> Result<crate::model::settings::RuntimeSettingsValues, AdminError> {
|
||||
Ok(self
|
||||
.settings
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "runtime settings"))?
|
||||
.values
|
||||
.reset_credit_lead_minutes)
|
||||
.values)
|
||||
}
|
||||
|
||||
pub(super) async fn automation_status(
|
||||
@@ -74,7 +75,7 @@ impl DefaultAccountsService {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ResetCreditAutomationStatus, AdminError> {
|
||||
self.require_reset_credit_automation(account_id).await?;
|
||||
let lead_minutes = self.automation_lead_minutes().await?;
|
||||
let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
let record = self
|
||||
.accounts
|
||||
.reset_credit_automation(account_id.as_str())
|
||||
@@ -160,7 +161,7 @@ impl DefaultAccountsService {
|
||||
record.state.next_discovery_at =
|
||||
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
|
||||
}
|
||||
let lead = self.automation_lead_minutes().await?;
|
||||
let lead = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
reschedule(&mut record, lead, now);
|
||||
self.save_automation(&mut record, Some(context)).await?;
|
||||
let settings = self
|
||||
@@ -244,7 +245,11 @@ impl DefaultAccountsService {
|
||||
}
|
||||
let now = Utc::now();
|
||||
self.observe_reset_credits(&mut record, credits, now)?;
|
||||
reschedule(&mut record, self.automation_lead_minutes().await?, now);
|
||||
reschedule(
|
||||
&mut record,
|
||||
self.automation_settings().await?.reset_credit_lead_minutes,
|
||||
now,
|
||||
);
|
||||
self.save_automation(&mut record, None).await
|
||||
}
|
||||
|
||||
@@ -281,6 +286,60 @@ impl DefaultAccountsService {
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
/// 暂不满足条件不丢弃卡片,也不创建未决消费,允许到期前再次判断
|
||||
async fn defer_automation(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead: u32,
|
||||
reason: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
let now = Utc::now();
|
||||
let retry = now + chrono::Duration::seconds(30 + i64::from(OsRng.next_u32() % 30));
|
||||
record.state.retry_at = Some(
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.map(|credit| credit.expires_at)
|
||||
.min()
|
||||
.map_or(retry, |expiry| retry.min(expiry)),
|
||||
);
|
||||
record.state.last_result = Some(reason.to_owned());
|
||||
record.state.failures = 0;
|
||||
reschedule(record, lead, now);
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
async fn weekly_reset_credit_gate(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
max_remaining: u32,
|
||||
) -> Result<Option<&'static str>, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
let quota = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?;
|
||||
// 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度
|
||||
let remaining = quota
|
||||
.usage_windows()
|
||||
.find(|(_, period)| *period == AccountUsagePeriod::Weekly)
|
||||
.and_then(|(window, _)| window.used_percent)
|
||||
.filter(|used| used.is_finite())
|
||||
.map(|used| 100.0 - used.clamp(0.0, 100.0));
|
||||
Ok(match remaining {
|
||||
Some(remaining) if remaining > f64::from(max_remaining) => {
|
||||
Some("weekly_quota_above_threshold")
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some("weekly_quota_unavailable"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_automation(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -309,7 +368,8 @@ impl DefaultAccountsService {
|
||||
}
|
||||
}
|
||||
let now = Utc::now();
|
||||
let lead = self.automation_lead_minutes().await?;
|
||||
let settings = self.automation_settings().await?;
|
||||
let lead = settings.reset_credit_lead_minutes;
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
@@ -368,6 +428,32 @@ impl DefaultAccountsService {
|
||||
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
|
||||
.cloned()
|
||||
{
|
||||
if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent {
|
||||
// 已发出的未决请求跳过此检查,继续沿原幂等键确认结果
|
||||
match self
|
||||
.weekly_reset_credit_gate(account_id, max_remaining)
|
||||
.await
|
||||
{
|
||||
Ok(Some(reason)) => {
|
||||
return self.defer_automation(&mut record, lead, reason).await;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.defer_automation(&mut record, lead, "weekly_quota_unavailable")
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费
|
||||
if credit.expires_at <= Utc::now() {
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.retain(|credit| credit.expires_at > Utc::now());
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
return self.save_automation(&mut record, None).await;
|
||||
}
|
||||
record.state.pending = Some(PendingResetCredit {
|
||||
credit,
|
||||
redeem_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -393,6 +479,11 @@ impl DefaultAccountsService {
|
||||
Ok(result) => {
|
||||
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
|
||||
record.state.pending = None;
|
||||
if result.code == "nothing_to_reset" {
|
||||
return self
|
||||
.defer_automation(&mut record, lead, "nothing_to_reset")
|
||||
.await;
|
||||
}
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
|
||||
@@ -1137,7 +1137,10 @@ impl AccountStore for FakeAccountStore {
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticSettingsStore;
|
||||
#[derive(Default)]
|
||||
struct StaticSettingsStore {
|
||||
reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for StaticSettingsStore {
|
||||
@@ -1197,6 +1200,8 @@ impl SettingsStore for StaticSettingsStore {
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: self
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1892,7 +1897,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
store.set_accounts(vec![openai_account, xai_account]);
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(openai)
|
||||
.provider(xai)
|
||||
.probe(Arc::new(SuccessfulAccountProbe))
|
||||
@@ -2100,7 +2105,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
store.set_accounts(vec![account_record("openai"), xai_account]);
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(openai)
|
||||
.provider(failing)
|
||||
.probe(Arc::new(SuccessfulAccountProbe))
|
||||
@@ -3457,7 +3462,7 @@ async fn accounts_service_with_probe(
|
||||
) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(provider)
|
||||
.probe(probe)
|
||||
.build()
|
||||
@@ -3701,7 +3706,7 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.account_runtime(runtime.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -309,7 +309,7 @@ async fn automation_restores_pending_idempotency_after_service_restart() {
|
||||
.state
|
||||
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
|
||||
drop(services);
|
||||
let restarted = accounts_service(provider.clone(), store.clone()).await;
|
||||
let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await;
|
||||
restarted
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id)
|
||||
@@ -707,3 +707,263 @@ async fn disabled_automation_only_blocks_manual_consume_until_pending_credit_exp
|
||||
.unwrap();
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
}
|
||||
|
||||
async fn threshold_services(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
max_remaining: Option<u32>,
|
||||
) -> AdminServices {
|
||||
super::super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore {
|
||||
reset_credit_max_weekly_remaining_percent: max_remaining,
|
||||
}))
|
||||
.provider(provider)
|
||||
.probe(Arc::new(SuccessfulAccountProbe))
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
fn weekly_quota(used_percent: f64) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "week".into(),
|
||||
group: "shortTerm".into(),
|
||||
label: "周额度".into(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(7 * 86_400),
|
||||
used_percent: Some(used_percent),
|
||||
reset_at: Some(Utc::now() + TimeDelta::days(1)),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}],
|
||||
..empty_quota()
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_due_credit(provider: &FakeProviderAdmin, store: &FakeAccountStore) {
|
||||
enable_capability(provider);
|
||||
let expiry = Utc::now() + TimeDelta::minutes(5);
|
||||
provider.reset_credits.lock().unwrap().push(card(expiry));
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), plan(expiry));
|
||||
}
|
||||
|
||||
fn advance_retry(store: &FakeAccountStore) {
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut("acct_test")
|
||||
.unwrap()
|
||||
.state
|
||||
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_quota() {
|
||||
for (threshold, used, consume) in [
|
||||
(None, 0.0, true),
|
||||
(Some(70), 29.9, false),
|
||||
(Some(70), 30.0, true),
|
||||
(Some(0), 99.9, false),
|
||||
(Some(0), 100.0, true),
|
||||
(Some(100), 0.0, true),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(used));
|
||||
let services = threshold_services(provider.clone(), store.clone(), threshold).await;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
!provider.reset_credit_commands().is_empty(),
|
||||
consume,
|
||||
"threshold={threshold:?}, used={used}"
|
||||
);
|
||||
let quota_requests = provider.quota_requests();
|
||||
// 消费成功已有一次回读;未设置上限时不增加消费前查询
|
||||
assert_eq!(
|
||||
quota_requests.len(),
|
||||
usize::from(threshold.is_some()) + usize::from(consume)
|
||||
);
|
||||
assert!(quota_requests.iter().all(|request| request.refresh));
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(record.state.pending.is_none());
|
||||
assert_eq!(record.state.credits.is_empty(), consume);
|
||||
if !consume {
|
||||
assert_eq!(
|
||||
record.state.last_result.as_deref(),
|
||||
Some("weekly_quota_above_threshold")
|
||||
);
|
||||
assert!(record.state.retry_at.unwrap() > Utc::now());
|
||||
assert!(record.state.retry_at.unwrap() <= record.state.credits[0].expires_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(10.0));
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
provider.set_quota(weekly_quota(40.0));
|
||||
advance_retry(&store);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(record.state.credits.is_empty());
|
||||
assert!(record.state.retry_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
|
||||
let weekly = weekly_quota(90.0).windows.remove(0);
|
||||
let mut short = weekly.clone();
|
||||
short.window_seconds = Some(18_000);
|
||||
let mut model = weekly.clone();
|
||||
model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let mut month = weekly.clone();
|
||||
month.group = "monthly".into();
|
||||
month.window_seconds = Some(30 * 86_400);
|
||||
let mut missing_percent = weekly.clone();
|
||||
missing_percent.used_percent = None;
|
||||
for (quota, failure) in [
|
||||
(empty_quota(), None),
|
||||
(
|
||||
ProviderQuota {
|
||||
windows: vec![short, model, month],
|
||||
..empty_quota()
|
||||
},
|
||||
None,
|
||||
),
|
||||
(
|
||||
ProviderQuota {
|
||||
windows: vec![missing_percent],
|
||||
..empty_quota()
|
||||
},
|
||||
None,
|
||||
),
|
||||
(weekly_quota(f64::NAN), None),
|
||||
(
|
||||
weekly_quota(90.0),
|
||||
Some(ProviderAdminErrorKind::Unavailable),
|
||||
),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(quota);
|
||||
*provider.quota_failure.lock().unwrap() = failure;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let result = services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await;
|
||||
assert_eq!(result.is_err(), failure.is_some());
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.state.last_result.as_deref(),
|
||||
Some("weekly_quota_unavailable")
|
||||
);
|
||||
assert!(record.state.pending.is_none());
|
||||
assert_eq!(record.state.credits.len(), 1);
|
||||
assert!(record.state.retry_at.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
*provider.reset_credit_result.lock().unwrap() = "nothing_to_reset".into();
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.state.last_result.as_deref(),
|
||||
Some("nothing_to_reset")
|
||||
);
|
||||
assert!(record.state.pending.is_none());
|
||||
assert_eq!(record.state.credits.len(), 1);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
advance_retry(&store);
|
||||
*provider.reset_credit_result.lock().unwrap() = "reset".into();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[0].credit_id, commands[1].credit_id);
|
||||
assert_ne!(commands[0].redeem_request_id, commands[1].redeem_request_id);
|
||||
assert!(
|
||||
store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state
|
||||
.credits
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1141,6 +1141,7 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ pub struct RuntimeSettingsFields {
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
|
||||
/// 运行配置投影与设置页字段的聚合响应
|
||||
@@ -787,6 +788,8 @@ impl RuntimeSettingsFields {
|
||||
account_warmup_schedule_time: self.account_warmup_schedule_time,
|
||||
account_warmup_model: self.account_warmup_model,
|
||||
reset_credit_lead_minutes: self.reset_credit_lead_minutes,
|
||||
reset_credit_max_weekly_remaining_percent: self
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
};
|
||||
values.validate().map_err(settings_validation_error)?;
|
||||
Ok(values)
|
||||
@@ -828,6 +831,8 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
reset_credit_lead_minutes: settings.reset_credit_lead_minutes,
|
||||
reset_credit_max_weekly_remaining_percent: settings
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -844,6 +849,7 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
|
||||
"account_warmup_enabled" => "accountWarmupEnabled",
|
||||
"account_warmup_model" => "accountWarmupModel",
|
||||
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
|
||||
"reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent",
|
||||
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
|
||||
"audit_retention_days" => "auditRetentionDays",
|
||||
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
|
||||
|
||||
@@ -647,6 +647,7 @@ impl SettingsStore for MemorySettingsStore {
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
};
|
||||
*settings = updated.clone();
|
||||
@@ -1826,6 +1827,7 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ fn update_body() -> Value {
|
||||
"accountWarmupEnabled": false,
|
||||
"accountWarmupScheduleTime": "08:00",
|
||||
"accountWarmupModel": null,
|
||||
"resetCreditLeadMinutes": 10
|
||||
"resetCreditLeadMinutes": 10,
|
||||
"resetCreditMaxWeeklyRemainingPercent": null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,6 +411,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -462,6 +464,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
"accountWarmupScheduleTime": "08:00",
|
||||
"accountWarmupModel": null,
|
||||
"resetCreditLeadMinutes": 10,
|
||||
"resetCreditMaxWeeklyRemainingPercent": null,
|
||||
"updatedAt": "2026-08-02T10:30:00Z",
|
||||
"updatedAtDisplay": "2026-08-02 18:30:00"
|
||||
})
|
||||
@@ -539,6 +542,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1642,3 +1646,42 @@ fn reset_credit_lead_minutes_requires_an_integer_within_one_day() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credit_weekly_threshold_accepts_unset_and_zero_to_one_hundred() {
|
||||
let mut omitted = update_body();
|
||||
omitted
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("resetCreditMaxWeeklyRemainingPercent");
|
||||
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(omitted).unwrap();
|
||||
request.validate().unwrap();
|
||||
assert!(
|
||||
request
|
||||
.values
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.is_none()
|
||||
);
|
||||
for value in [json!(null), json!(0), json!(70), json!(100)] {
|
||||
let mut body = update_body();
|
||||
body["resetCreditMaxWeeklyRemainingPercent"] = value.clone();
|
||||
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
|
||||
request.validate().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(request.values.reset_credit_max_weekly_remaining_percent).unwrap(),
|
||||
value
|
||||
);
|
||||
}
|
||||
let mut body = update_body();
|
||||
body["resetCreditMaxWeeklyRemainingPercent"] = json!(101);
|
||||
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(
|
||||
request.validate().unwrap_err().field(),
|
||||
"resetCreditMaxWeeklyRemainingPercent"
|
||||
);
|
||||
for value in [json!(-1), json!(70.5), json!("70")] {
|
||||
let mut body = update_body();
|
||||
body["resetCreditMaxWeeklyRemainingPercent"] = value;
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,8 +281,8 @@ HTTP 总入口统一包裹原生路由,`dispatch_http` 复用同一路由与
|
||||
`accounts::GetResetCreditAutomation` 接收账号 ID,返回 `ResetCreditAutomationStatus`:开关、全局提前分钟数、
|
||||
下次发现与使用时间、最近结果;时间为 RFC 3339 字符串,无计划时为 `None`。
|
||||
`accounts::SetResetCreditAutomation` 接收 `(account_id, enabled)`,返回保存后的同一状态;审计主体与调用标识由宿主提供。
|
||||
两者复用原生账号校验和自动重置用例,不立即查询或消费上游卡片。提前时间通过 `settings::Load` / `Replace` 的
|
||||
`reset_credit_lead_minutes` 读取和设置,范围见[主动额度重置卡](../../../../../docs/api.md#主动额度重置卡)
|
||||
两者复用原生账号校验和自动重置用例,不立即查询或消费上游卡片。提前时间与剩余周额度上限通过 `settings::Load` / `Replace` 的
|
||||
`reset_credit_lead_minutes` 和可选的 `reset_credit_max_weekly_remaining_percent` 读取和设置,范围见[主动额度重置卡](../../../../../docs/api.md#主动额度重置卡)
|
||||
|
||||
```rust,ignore
|
||||
use gateway_plugin_sdk::call::services::accounts::{GetResetCreditAutomation, SetResetCreditAutomation};
|
||||
|
||||
@@ -125,6 +125,7 @@ pub struct RuntimeSettings {
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
pub request_profiles: ProviderRequestProfiles,
|
||||
pub config_revision: Revision,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -165,6 +166,7 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
|
||||
pub expected_revision: Revision,
|
||||
/// 只覆盖提交的 Provider;未提交项保留当前持久值
|
||||
@@ -368,6 +370,8 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
reset_credit_lead_minutes: settings.reset_credit_lead_minutes,
|
||||
reset_credit_max_weekly_remaining_percent: settings
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
expected_revision: settings.config_revision,
|
||||
request_profile_updates: settings
|
||||
.request_profiles
|
||||
|
||||
@@ -159,7 +159,7 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul
|
||||
account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds,
|
||||
account_auto_freeze_probe_enabled, account_auto_freeze_probe_model,
|
||||
account_auto_freeze_adaptive_concurrency,
|
||||
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes
|
||||
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent
|
||||
from runtime_settings where id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
@@ -337,7 +337,7 @@ pub(crate) async fn load_runtime_settings_in_transaction(
|
||||
account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds,
|
||||
account_auto_freeze_probe_enabled, account_auto_freeze_probe_model,
|
||||
account_auto_freeze_adaptive_concurrency,
|
||||
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes
|
||||
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent
|
||||
from runtime_settings where id = 1",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
@@ -356,12 +356,12 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
update: &RuntimeSettingsUpdate,
|
||||
) -> StoreResult<Revision> {
|
||||
update.validate()?;
|
||||
let previous_lead: i32 = sqlx::query_scalar(
|
||||
"select reset_credit_lead_minutes from runtime_settings where id = 1 for update",
|
||||
let previous_reset_settings: (i32, Option<i32>) = sqlx::query_as(
|
||||
"select reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent from runtime_settings where id = 1 for update",
|
||||
)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await
|
||||
.map_err(|source| postgres_unavailable("load reset-credit lead time", source))?;
|
||||
.map_err(|source| postgres_unavailable("load reset-credit settings", source))?;
|
||||
let refresh_margin_seconds =
|
||||
i64::try_from(update.values.refresh_margin_seconds).map_err(|_| invalid_numeric())?;
|
||||
let request_profile_updates = update
|
||||
@@ -419,6 +419,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
openai_session_affinity_ttl_hours = $33,
|
||||
codex_privacy_policy_json = $34,
|
||||
reset_credit_lead_minutes = $35,
|
||||
reset_credit_max_weekly_remaining_percent = $36,
|
||||
updated_at = now()
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
@@ -469,6 +470,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
.bind(i64::from(update.values.openai_session_affinity_ttl_hours))
|
||||
.bind(sqlx::types::Json(&update.values.codex_privacy_policy))
|
||||
.bind(i64::from(update.values.reset_credit_lead_minutes))
|
||||
.bind(update.values.reset_credit_max_weekly_remaining_percent.map(i64::from))
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|source| postgres_unavailable("update runtime settings in transaction", source))?
|
||||
@@ -477,8 +479,14 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
entity: "runtime settings",
|
||||
id: "1".to_owned(),
|
||||
})?;
|
||||
// 提前量变化只唤醒本地计划重算,不触发账号全量上游查询
|
||||
if i64::from(previous_lead) != i64::from(update.values.reset_credit_lead_minutes) {
|
||||
// 自动使用配置变化只唤醒本地计划重算,不触发账号全量上游查询
|
||||
if i64::from(previous_reset_settings.0) != i64::from(update.values.reset_credit_lead_minutes)
|
||||
|| previous_reset_settings.1.map(i64::from)
|
||||
!= update
|
||||
.values
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.map(i64::from)
|
||||
{
|
||||
sqlx::query("update provider_accounts set reset_credit_next_run_at = least(reset_credit_next_run_at, now()), reset_credit_automation_json = jsonb_set(reset_credit_automation_json, '{revision}', to_jsonb((reset_credit_automation_json ->> 'revision')::bigint + 1)) where reset_credit_automation_json ->> 'enabled' = 'true' ")
|
||||
.execute(&mut **transaction).await
|
||||
.map_err(|source| postgres_unavailable("reschedule reset-credit automation", source))?;
|
||||
@@ -566,6 +574,7 @@ struct RuntimeSettingsRow {
|
||||
account_warmup_schedule_time: String,
|
||||
account_warmup_model: Option<String>,
|
||||
reset_credit_lead_minutes: i32,
|
||||
reset_credit_max_weekly_remaining_percent: Option<i32>,
|
||||
}
|
||||
|
||||
fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSettings> {
|
||||
@@ -635,6 +644,11 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
|
||||
account_warmup_model: row.account_warmup_model,
|
||||
reset_credit_lead_minutes: u32::try_from(row.reset_credit_lead_minutes)
|
||||
.map_err(|_| invalid_numeric())?,
|
||||
reset_credit_max_weekly_remaining_percent: row
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.map(u32::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| invalid_numeric())?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(super) fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettin
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -213,6 +214,7 @@ async fn privacy_steps_migration_preserves_rules_and_other_settings() {
|
||||
.unwrap();
|
||||
expected_row["codex_privacy_policy_json"] = expected.clone();
|
||||
expected_row["reset_credit_lead_minutes"] = serde_json::json!(10);
|
||||
expected_row["reset_credit_max_weekly_remaining_percent"] = serde_json::Value::Null;
|
||||
// 完整升级还会清理自动回切字段,其余运行设置必须原样保留
|
||||
expected_row["smart_scheduling_json"]
|
||||
.as_object_mut()
|
||||
@@ -1485,3 +1487,41 @@ async fn removing_switchback_preserves_custom_weights_and_replaces_database_defa
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_credit_weekly_threshold_upgrade_defaults_unset_and_round_trips() {
|
||||
let Some(database) = TestDatabase::create_through("reset_weekly_threshold", 34).await else {
|
||||
return;
|
||||
};
|
||||
sqlx::query("update runtime_settings set reset_credit_lead_minutes = 20 where id = 1")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
super::TEST_MIGRATOR.run(&database.pool).await.unwrap();
|
||||
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
||||
let settings = repository.load_runtime_settings().await.unwrap();
|
||||
assert_eq!(settings.values.reset_credit_lead_minutes, 20);
|
||||
assert_eq!(
|
||||
settings.values.reset_credit_max_weekly_remaining_percent,
|
||||
None
|
||||
);
|
||||
for threshold in [Some(70), Some(0), Some(100), None] {
|
||||
let mut update = settings_with_margin(600);
|
||||
update.values.reset_credit_max_weekly_remaining_percent = threshold;
|
||||
repository.update_runtime_settings(update).await.unwrap();
|
||||
assert_eq!(
|
||||
repository
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.unwrap()
|
||||
.values
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
threshold
|
||||
);
|
||||
}
|
||||
for threshold in [-1, 101] {
|
||||
assert!(sqlx::query("update runtime_settings set reset_credit_max_weekly_remaining_percent = $1 where id = 1")
|
||||
.bind(threshold).execute(&database.pool).await.is_err());
|
||||
}
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -32,3 +32,4 @@ b49832e205405de7ef078f3b65c0c19f2c030b8984d42411598738b6b6c146bf 0031_proxy_and
|
||||
a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and_scheduling.sql
|
||||
6acd13f237cd163fdf9b52ec9c003ca75b723980e5db5c52c9776413cfe85c4b 0033_proxy_websocket_capacity.sql
|
||||
0bcd3b00ad7038d9a62e955ff607032f072b08c9f529cf43f794a9820965bdbd 0034_reset_credit_automation.sql
|
||||
3e8ba22f9611c31df3e1159f6a5c237c2dd0020751ae83c029de8b23623ab068 0035_reset_credit_weekly_threshold.sql
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 自动使用重置卡的剩余周额度上限,空值表示不限制
|
||||
ALTER TABLE runtime_settings
|
||||
ADD COLUMN reset_credit_max_weekly_remaining_percent integer
|
||||
CHECK (reset_credit_max_weekly_remaining_percent BETWEEN 0 AND 100);
|
||||
+9
-2
@@ -1283,6 +1283,10 @@ revision 变化时丢弃结果
|
||||
GET 和 POST 均返回 `enabled`、`leadMinutes`、`nextDiscoveryAtDisplay`、`nextConsumeAtDisplay`、`lastResult`,
|
||||
时间展示沿用部署时区,无计划时为 `null`。运行设置 `resetCreditLeadMinutes` 默认 10,范围 1~1440 分钟
|
||||
|
||||
`resetCreditMaxWeeklyRemainingPercent` 是可选的全局剩余周额度上限,范围 0~100 的整数,默认 `null` 表示不限制。
|
||||
设置后,每次新的自动消费前刷新账号周额度;剩余比例大于上限或无法确认周额度时,保留卡片并在到期前错峰重查,
|
||||
等于上限时允许使用。仅采用账号级周窗口,不回退到短期、月度或模型专属额度;手动使用不受此上限影响
|
||||
|
||||
开启后在一分钟内错峰执行首次发现,此后按部署时区每天随机发现一次,普通发现失败也等待下一日。
|
||||
已知且有明确到期时间的可用卡片在提前窗口内错峰执行,消费前重新查询;已在其他地方使用或已到期的卡片
|
||||
会移出计划,查询失败不直接消费。上游返回 `already_redeemed` 后结束该次操作,不切换另一张卡重试。
|
||||
@@ -1290,7 +1294,10 @@ GET 和 POST 均返回 `enabled`、`leadMinutes`、`nextDiscoveryAtDisplay`、`n
|
||||
|
||||
`lastResult` 的 `pending` 表示消费结果待确认;重试沿用已持久化的幂等键,重启或关闭再开启也不换键。
|
||||
`expired_unknown` 表示未确认操作的卡片已经到期,停止重试;`reset`、`already_redeemed` 表示对应终态,
|
||||
`discovery_failed` 表示最近查询失败,`not_redeemed` 表示上游明确未完成消费。自动操作未决且对应卡片尚未到期时,手动消费返回 `409`
|
||||
`discovery_failed` 表示最近查询失败,`not_redeemed` 表示上游明确未完成消费。
|
||||
`weekly_quota_above_threshold` 表示剩余周额度高于上限,`weekly_quota_unavailable` 表示暂时无法确认周额度;
|
||||
`nothing_to_reset` 表示当前没有可重置窗口,这三种状态均保留卡片并稍后重查,其中 `nothing_to_reset` 的下一次消费使用新幂等键。
|
||||
结果未知的未决操作继续使用原幂等键确认,不重新应用周额度上限;自动操作未决且对应卡片尚未到期时,手动消费返回 `409`
|
||||
|
||||
## 6. 账号分组
|
||||
|
||||
@@ -1473,7 +1480,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
|
||||
| [历史保留](#历史保留) | `usageRetentionDays`、`opsEventRetentionDays`、`auditRetentionDays` |
|
||||
| [账号自动冻结](#账号自动冻结) | `accountAutoFreezeEnabled`、`accountAutoFreezeThreshold`、`accountAutoFreezeWindowSeconds`、`accountAutoFreezeDurationSeconds`、`accountAutoFreezeProbeEnabled`、`accountAutoFreezeProbeModel`、`accountAutoFreezeAdaptiveConcurrency` |
|
||||
| [账号预热](#账号预热) | `accountWarmupEnabled`、`accountWarmupScheduleTime`、`accountWarmupModel` |
|
||||
| [主动额度重置卡](#主动额度重置卡) | `resetCreditLeadMinutes` |
|
||||
| [主动额度重置卡](#主动额度重置卡) | `resetCreditLeadMinutes`、`resetCreditMaxWeeklyRemainingPercent` |
|
||||
|
||||
### 历史保留
|
||||
|
||||
|
||||
@@ -900,7 +900,9 @@ Admin 仅为开启自动使用的账号保存卡片 ID、到期时间、下一
|
||||
已知到期计划在提前窗口前段错峰;Worker 租约限制为单个执行者,每轮最多处理 100 个到期账号,并发复用
|
||||
凭据刷新配置。周期扫描只访问本地计划,消费前重新核对上游;外部已消费的卡片移出计划,已消费响应为终态。
|
||||
消费幂等键先持久化再发送,结果未确认时退避重试,到期后停止;确认结果先落库再刷新额度。
|
||||
计划写入通过版本比较拒绝过期覆盖,全局提前时间变更唤醒本地重算,关闭自动使用保留未决键供重新开启后恢复
|
||||
设置剩余周额度上限时,Admin 在新消费前通过 Provider 刷新额度,复用账号周窗口归属;超过上限或无法确认时保留卡片重查。
|
||||
上游明确返回 `nothing_to_reset` 时清除未决键并保留卡片,下一次消费使用新键;未确认操作始终沿原键完成确认。
|
||||
计划写入通过版本比较拒绝过期覆盖,全局提前时间或周额度上限变更唤醒本地重算,关闭自动使用保留未决键供重新开启后恢复
|
||||
|
||||
## 10. 观测与后台任务
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface RuntimeSettings {
|
||||
accountWarmupScheduleTime: string
|
||||
accountWarmupModel: string | null
|
||||
resetCreditLeadMinutes: number
|
||||
resetCreditMaxWeeklyRemainingPercent: number | null
|
||||
updatedAt: string
|
||||
updatedAtDisplay: string
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ const resultText = computed(() => {
|
||||
case 'pending': return status.value?.enabled ? '上次自动重置结果待确认,将继续核对' : '上次自动重置结果待确认'
|
||||
case 'expired_unknown': return '上次重置结果未能确认,卡片已到期'
|
||||
case 'discovery_failed': return '最近查询失败,将按计划重试'
|
||||
case 'weekly_quota_above_threshold': return '剩余周额度高于上限,稍后检查'
|
||||
case 'weekly_quota_unavailable': return '暂时无法确认周额度,稍后检查'
|
||||
case 'nothing_to_reset': return '当前额度窗口不需要重置,稍后检查'
|
||||
case 'not_redeemed': return '上游未完成最近一次自动重置'
|
||||
default: return ''
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ZCard, ZForm, ZFormItem, ZInput } from '@codex-proxy/ui'
|
||||
|
||||
const leadMinutes = defineModel<string>({ required: true })
|
||||
const maxWeeklyRemainingPercent = defineModel<string>('maxWeeklyRemainingPercent', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,6 +11,9 @@ const leadMinutes = defineModel<string>({ required: true })
|
||||
<ZFormItem label="提前使用时间(分钟)" description="全局设置,对已开启自动使用的账号生效">
|
||||
<ZInput v-model="leadMinutes" type="number" min="1" max="1440" step="1" aria-label="重置卡提前使用时间" />
|
||||
</ZFormItem>
|
||||
<ZFormItem label="剩余周额度上限(%)" description="高于此值时暂不自动使用,留空表示不限制">
|
||||
<ZInput v-model="maxWeeklyRemainingPercent" type="number" min="0" max="100" step="1" placeholder="不限制" aria-label="重置卡剩余周额度上限" />
|
||||
</ZFormItem>
|
||||
</ZForm>
|
||||
</ZCard>
|
||||
</template>
|
||||
|
||||
@@ -61,6 +61,7 @@ export function useSettingsForm() {
|
||||
accountWarmupScheduleTime: '08:00',
|
||||
accountWarmupModel: '',
|
||||
resetCreditLeadMinutes: 10 as number | null,
|
||||
resetCreditMaxWeeklyRemainingPercent: null as number | null,
|
||||
})
|
||||
|
||||
function snapshot() {
|
||||
@@ -79,7 +80,7 @@ export function useSettingsForm() {
|
||||
mappings.value = initial.mappings
|
||||
}
|
||||
|
||||
function numericModel(key: 'resetCreditLeadMinutes' | 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'maxAccountRotations' | 'openaiSessionAffinityTtlHours' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'usageRetentionDays' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
|
||||
function numericModel(key: 'resetCreditMaxWeeklyRemainingPercent' | 'resetCreditLeadMinutes' | 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'maxAccountRotations' | 'openaiSessionAffinityTtlHours' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'usageRetentionDays' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
|
||||
return computed({
|
||||
get: () => (form[key] === null ? '' : String(form[key])),
|
||||
set: (value: string) => {
|
||||
@@ -103,6 +104,7 @@ export function useSettingsForm() {
|
||||
const maxWaitingPerAccountValue = numericModel('maxWaitingPerAccount')
|
||||
const responsesMaxDecompressedBodyMiBValue = numericModel('responsesMaxDecompressedBodyMiB')
|
||||
const resetCreditLeadMinutesValue = numericModel('resetCreditLeadMinutes')
|
||||
const resetCreditMaxWeeklyRemainingPercentValue = numericModel('resetCreditMaxWeeklyRemainingPercent')
|
||||
const usageRetentionDaysValue = numericModel('usageRetentionDays')
|
||||
const concurrencyWaitTimeoutSecondsValue = numericModel('concurrencyWaitTimeoutSeconds')
|
||||
const accountAutoFreezeThresholdValue = numericModel('accountAutoFreezeThreshold')
|
||||
@@ -154,6 +156,7 @@ export function useSettingsForm() {
|
||||
form.accountWarmupScheduleTime = data.accountWarmupScheduleTime ?? '08:00'
|
||||
form.accountWarmupModel = data.accountWarmupModel ?? ''
|
||||
form.resetCreditLeadMinutes = data.resetCreditLeadMinutes
|
||||
form.resetCreditMaxWeeklyRemainingPercent = data.resetCreditMaxWeeklyRemainingPercent
|
||||
mappings.value = Object.entries(data.modelMappings).map(([requestedModel, upstreamModel]) => ({
|
||||
requestedModel,
|
||||
upstreamModel,
|
||||
@@ -261,6 +264,11 @@ export function useSettingsForm() {
|
||||
ZNotification.warning({ message: 'Responses 解压上限应为有效的正整数(MiB)' })
|
||||
return
|
||||
}
|
||||
const resetCreditMaxWeeklyRemainingPercent = form.resetCreditMaxWeeklyRemainingPercent
|
||||
if (resetCreditMaxWeeklyRemainingPercent !== null && (!Number.isInteger(resetCreditMaxWeeklyRemainingPercent) || resetCreditMaxWeeklyRemainingPercent < 0 || resetCreditMaxWeeklyRemainingPercent > 100)) {
|
||||
ZNotification.warning({ message: '剩余周额度上限应为 0~100 的整数,留空表示不限制' })
|
||||
return
|
||||
}
|
||||
const resetCreditLeadMinutes = form.resetCreditLeadMinutes
|
||||
if (resetCreditLeadMinutes === null || !Number.isInteger(resetCreditLeadMinutes) || resetCreditLeadMinutes < 1 || resetCreditLeadMinutes > 1440) {
|
||||
ZNotification.warning({ message: '重置卡提前使用时间应为 1~1440 分钟的整数' })
|
||||
@@ -359,6 +367,7 @@ export function useSettingsForm() {
|
||||
accountWarmupScheduleTime: scheduleTime,
|
||||
accountWarmupModel: warmupModel || null,
|
||||
resetCreditLeadMinutes,
|
||||
resetCreditMaxWeeklyRemainingPercent,
|
||||
})
|
||||
applySettings(result)
|
||||
ZNotification.success({ message: '设置已保存' })
|
||||
@@ -396,6 +405,7 @@ export function useSettingsForm() {
|
||||
responsesMaxDecompressedBodyMiBValue,
|
||||
usageRetentionDaysValue,
|
||||
resetCreditLeadMinutesValue,
|
||||
resetCreditMaxWeeklyRemainingPercentValue,
|
||||
accountAutoFreezeThresholdValue,
|
||||
accountAutoFreezeWindowSecondsValue,
|
||||
accountAutoFreezeDurationSecondsValue,
|
||||
|
||||
@@ -75,6 +75,7 @@ const {
|
||||
responsesMaxDecompressedBodyMiBValue,
|
||||
usageRetentionDaysValue,
|
||||
resetCreditLeadMinutesValue,
|
||||
resetCreditMaxWeeklyRemainingPercentValue,
|
||||
accountAutoFreezeThresholdValue,
|
||||
accountAutoFreezeWindowSecondsValue,
|
||||
accountAutoFreezeDurationSecondsValue,
|
||||
@@ -194,7 +195,10 @@ watch(section, (value) => {
|
||||
v-model:schedule-time="form.accountWarmupScheduleTime"
|
||||
v-model:model="form.accountWarmupModel"
|
||||
/>
|
||||
<ResetCreditCard v-model="resetCreditLeadMinutesValue" />
|
||||
<ResetCreditCard
|
||||
v-model="resetCreditLeadMinutesValue"
|
||||
v-model:max-weekly-remaining-percent="resetCreditMaxWeeklyRemainingPercentValue"
|
||||
/>
|
||||
<RequestHistoryCard v-model="usageRetentionDaysValue" />
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user