mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Unify quota snapshots and oauth refresh handling
This commit is contained in:
@@ -15,8 +15,15 @@ fn local_candidate_index(report_context: Option<&serde_json::Value>) -> Option<u
|
|||||||
.and_then(serde_json::Value::as_u64)
|
.and_then(serde_json::Value::as_u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_retryable_local_upstream_status(status_code: u16) -> bool {
|
fn should_failover_local_upstream_status(status_code: u16) -> bool {
|
||||||
status_code == 429 || status_code >= 500
|
status_code >= 400
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_plan_kind_disables_local_candidate_failover(plan_kind: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
plan_kind,
|
||||||
|
"openai_video_delete_sync" | "openai_video_cancel_sync" | "gemini_video_cancel_sync"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
@@ -54,11 +61,14 @@ impl LocalFailoverDecision {
|
|||||||
pub(crate) async fn should_retry_next_local_candidate_sync(
|
pub(crate) async fn should_retry_next_local_candidate_sync(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
plan: &ExecutionPlan,
|
plan: &ExecutionPlan,
|
||||||
_plan_kind: &str,
|
plan_kind: &str,
|
||||||
report_context: Option<&serde_json::Value>,
|
report_context: Option<&serde_json::Value>,
|
||||||
result: &ExecutionResult,
|
result: &ExecutionResult,
|
||||||
response_text: Option<&str>,
|
response_text: Option<&str>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
if sync_plan_kind_disables_local_candidate_failover(plan_kind) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
matches!(
|
matches!(
|
||||||
resolve_local_failover_decision(
|
resolve_local_failover_decision(
|
||||||
state,
|
state,
|
||||||
@@ -75,11 +85,14 @@ pub(crate) async fn should_retry_next_local_candidate_sync(
|
|||||||
pub(crate) async fn should_stop_local_candidate_failover_sync(
|
pub(crate) async fn should_stop_local_candidate_failover_sync(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
plan: &ExecutionPlan,
|
plan: &ExecutionPlan,
|
||||||
_plan_kind: &str,
|
plan_kind: &str,
|
||||||
report_context: Option<&serde_json::Value>,
|
report_context: Option<&serde_json::Value>,
|
||||||
result: &ExecutionResult,
|
result: &ExecutionResult,
|
||||||
response_text: Option<&str>,
|
response_text: Option<&str>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
if sync_plan_kind_disables_local_candidate_failover(plan_kind) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
matches!(
|
matches!(
|
||||||
resolve_local_failover_decision(
|
resolve_local_failover_decision(
|
||||||
state,
|
state,
|
||||||
@@ -294,7 +307,7 @@ async fn resolve_local_failover_decision(
|
|||||||
return LocalFailoverDecision::RetryNextCandidate;
|
return LocalFailoverDecision::RetryNextCandidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_retryable_local_upstream_status(status_code) {
|
if should_failover_local_upstream_status(status_code) {
|
||||||
return LocalFailoverDecision::RetryNextCandidate;
|
return LocalFailoverDecision::RetryNextCandidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,6 +863,87 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_retry_next_candidate_treats_client_error_as_failover_by_default() {
|
||||||
|
let result = ExecutionResult {
|
||||||
|
request_id: "req-1".to_string(),
|
||||||
|
candidate_id: None,
|
||||||
|
status_code: 401,
|
||||||
|
headers: Default::default(),
|
||||||
|
body: None,
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
let local_report_context = serde_json::json!({
|
||||||
|
"candidate_index": 0,
|
||||||
|
"retry_index": 0,
|
||||||
|
});
|
||||||
|
let state = build_state_with_provider_config(None);
|
||||||
|
let plan = sample_plan();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
should_retry_next_local_candidate_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
Some("{\"error\":{\"message\":\"invalid auth token\"}}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_retry_next_candidate_skips_video_follow_up_plan_kinds() {
|
||||||
|
let result = ExecutionResult {
|
||||||
|
request_id: "req-1".to_string(),
|
||||||
|
candidate_id: None,
|
||||||
|
status_code: 404,
|
||||||
|
headers: Default::default(),
|
||||||
|
body: None,
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
let local_report_context = serde_json::json!({
|
||||||
|
"candidate_index": 0,
|
||||||
|
"retry_index": 0,
|
||||||
|
});
|
||||||
|
let state = build_state_with_provider_config(None);
|
||||||
|
let plan = sample_plan();
|
||||||
|
|
||||||
|
for plan_kind in [
|
||||||
|
"openai_video_delete_sync",
|
||||||
|
"openai_video_cancel_sync",
|
||||||
|
"gemini_video_cancel_sync",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!should_retry_next_local_candidate_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
plan_kind,
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
"{plan_kind} should not retry local failover candidates"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!should_stop_local_candidate_failover_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
plan_kind,
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
"{plan_kind} should not use local failover stop decisions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stream_retry_next_candidate_requires_local_candidate_context() {
|
async fn stream_retry_next_candidate_requires_local_candidate_context() {
|
||||||
let local_report_context = serde_json::json!({
|
let local_report_context = serde_json::json!({
|
||||||
@@ -927,6 +1021,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stream_retry_next_candidate_treats_client_error_as_failover_by_default() {
|
||||||
|
let local_report_context = serde_json::json!({
|
||||||
|
"candidate_index": 0,
|
||||||
|
"retry_index": 0,
|
||||||
|
});
|
||||||
|
let state = build_state_with_provider_config(None);
|
||||||
|
let plan = sample_plan();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
should_retry_next_local_candidate_stream(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_stream",
|
||||||
|
Some(&local_report_context),
|
||||||
|
403,
|
||||||
|
Some("{\"error\":{\"message\":\"invalid auth token\"}}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_local_failover_policy_reads_provider_rules() {
|
fn resolve_local_failover_policy_reads_provider_rules() {
|
||||||
let state = build_state_with_provider_config(Some(serde_json::json!({
|
let state = build_state_with_provider_config(Some(serde_json::json!({
|
||||||
|
|||||||
@@ -195,10 +195,15 @@ pub(super) async fn maybe_handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let Some(payload) = (match normalized_provider_type.as_str() {
|
let Some(payload) = (match normalized_provider_type.as_str() {
|
||||||
"codex" => refresh_codex_provider_quota_locally(state, &provider, &endpoint, keys).await?,
|
"codex" => {
|
||||||
"kiro" => refresh_kiro_provider_quota_locally(state, &provider, &endpoint, keys).await?,
|
refresh_codex_provider_quota_locally(state, &provider, &endpoint, keys, None).await?
|
||||||
|
}
|
||||||
|
"kiro" => {
|
||||||
|
refresh_kiro_provider_quota_locally(state, &provider, &endpoint, keys, None).await?
|
||||||
|
}
|
||||||
"antigravity" => {
|
"antigravity" => {
|
||||||
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys).await?
|
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys, None)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}) else {
|
}) else {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use crate::handlers::admin::provider::oauth::provisioning::{
|
|||||||
update_existing_provider_oauth_catalog_key,
|
update_existing_provider_oauth_catalog_key,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::oauth::runtime::{
|
use crate::handlers::admin::provider::oauth::runtime::{
|
||||||
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
|
provider_oauth_runtime_endpoint_for_provider,
|
||||||
|
spawn_provider_oauth_account_state_refresh_after_update,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::oauth::state::{
|
use crate::handlers::admin::provider::oauth::state::{
|
||||||
admin_provider_oauth_template, exchange_admin_provider_oauth_refresh_token,
|
admin_provider_oauth_template, exchange_admin_provider_oauth_refresh_token,
|
||||||
@@ -264,9 +265,12 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ =
|
spawn_provider_oauth_account_state_refresh_after_update(
|
||||||
refresh_provider_oauth_account_state_after_update(state, &provider, &persisted_key.id)
|
state.cloned_app(),
|
||||||
.await;
|
provider.clone(),
|
||||||
|
persisted_key.id.clone(),
|
||||||
|
request_proxy.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
success += 1;
|
success += 1;
|
||||||
results.push(json!({
|
results.push(json!({
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use crate::handlers::admin::provider::oauth::provisioning::{
|
|||||||
update_existing_provider_oauth_catalog_key,
|
update_existing_provider_oauth_catalog_key,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::oauth::runtime::{
|
use crate::handlers::admin::provider::oauth::runtime::{
|
||||||
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
|
provider_oauth_runtime_endpoint_for_provider,
|
||||||
|
spawn_provider_oauth_account_state_refresh_after_update,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::oauth::state::decode_jwt_claims;
|
use crate::handlers::admin::provider::oauth::state::decode_jwt_claims;
|
||||||
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
|
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
|
||||||
@@ -548,9 +549,12 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
|
|||||||
.get("auth_method")
|
.get("auth_method")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(serde_json::Value::Null);
|
.unwrap_or(serde_json::Value::Null);
|
||||||
let _ =
|
spawn_provider_oauth_account_state_refresh_after_update(
|
||||||
refresh_provider_oauth_account_state_after_update(state, &provider, &persisted_key.id)
|
state.cloned_app(),
|
||||||
.await;
|
provider.clone(),
|
||||||
|
persisted_key.id.clone(),
|
||||||
|
request_proxy.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
success += 1;
|
success += 1;
|
||||||
results.push(json!({
|
results.push(json!({
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
|||||||
&callback.code,
|
&callback.code,
|
||||||
&callback.state_nonce,
|
&callback.state_nonce,
|
||||||
state_data.pkce_verifier.as_deref(),
|
state_data.pkce_verifier.as_deref(),
|
||||||
request_proxy,
|
request_proxy.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -243,6 +243,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
|||||||
&provider,
|
&provider,
|
||||||
&endpoint,
|
&endpoint,
|
||||||
vec![refreshed_key],
|
vec![refreshed_key],
|
||||||
|
request_proxy.clone(),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use super::super::super::provisioning::{
|
|||||||
provider_oauth_active_api_formats, update_existing_provider_oauth_catalog_key,
|
provider_oauth_active_api_formats, update_existing_provider_oauth_catalog_key,
|
||||||
};
|
};
|
||||||
use super::super::super::runtime::{
|
use super::super::super::runtime::{
|
||||||
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
|
provider_oauth_runtime_endpoint_for_provider,
|
||||||
|
spawn_provider_oauth_account_state_refresh_after_update,
|
||||||
};
|
};
|
||||||
use super::super::super::state::{
|
use super::super::super::state::{
|
||||||
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
||||||
@@ -137,7 +138,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
|
|||||||
&callback.code,
|
&callback.code,
|
||||||
&callback.state_nonce,
|
&callback.state_nonce,
|
||||||
state_data.pkce_verifier.as_deref(),
|
state_data.pkce_verifier.as_deref(),
|
||||||
request_proxy,
|
request_proxy.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -231,9 +232,12 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = state
|
spawn_provider_oauth_account_state_refresh_after_update(
|
||||||
.refresh_provider_oauth_account_state_after_update(&provider, &persisted_key.id)
|
state.cloned_app(),
|
||||||
.await;
|
provider.clone(),
|
||||||
|
persisted_key.id.clone(),
|
||||||
|
request_proxy.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"key_id": persisted_key.id,
|
"key_id": persisted_key.id,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use crate::handlers::admin::provider::oauth::provisioning::{
|
|||||||
update_existing_provider_oauth_catalog_key,
|
update_existing_provider_oauth_catalog_key,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::oauth::runtime::{
|
use crate::handlers::admin::provider::oauth::runtime::{
|
||||||
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
|
provider_oauth_runtime_endpoint_for_provider,
|
||||||
|
spawn_provider_oauth_account_state_refresh_after_update,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::oauth::state::{
|
use crate::handlers::admin::provider::oauth::state::{
|
||||||
build_admin_provider_oauth_backend_unavailable_response, build_kiro_device_key_name,
|
build_admin_provider_oauth_backend_unavailable_response, build_kiro_device_key_name,
|
||||||
@@ -149,7 +150,7 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
|
|||||||
&session.client_id,
|
&session.client_id,
|
||||||
&session.client_secret,
|
&session.client_secret,
|
||||||
&session.device_code,
|
&session.device_code,
|
||||||
request_proxy,
|
request_proxy.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -315,9 +316,12 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = state
|
spawn_provider_oauth_account_state_refresh_after_update(
|
||||||
.refresh_provider_oauth_account_state_after_update(&provider, &persisted_key.id)
|
state.cloned_app(),
|
||||||
.await;
|
provider.clone(),
|
||||||
|
persisted_key.id.clone(),
|
||||||
|
request_proxy.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
session.status = "authorized".to_string();
|
session.status = "authorized".to_string();
|
||||||
session.key_id = Some(persisted_key.id.clone());
|
session.key_id = Some(persisted_key.id.clone());
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use super::super::provisioning::{
|
|||||||
provider_oauth_active_api_formats, update_existing_provider_oauth_catalog_key,
|
provider_oauth_active_api_formats, update_existing_provider_oauth_catalog_key,
|
||||||
};
|
};
|
||||||
use super::super::runtime::{
|
use super::super::runtime::{
|
||||||
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
|
provider_oauth_runtime_endpoint_for_provider,
|
||||||
|
spawn_provider_oauth_account_state_refresh_after_update,
|
||||||
};
|
};
|
||||||
use super::super::state::{
|
use super::super::state::{
|
||||||
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
||||||
@@ -120,7 +121,11 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let token_payload = match state
|
let token_payload = match state
|
||||||
.exchange_admin_provider_oauth_refresh_token(template, refresh_token_input, request_proxy)
|
.exchange_admin_provider_oauth_refresh_token(
|
||||||
|
template,
|
||||||
|
refresh_token_input,
|
||||||
|
request_proxy.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(payload) => payload,
|
Ok(payload) => payload,
|
||||||
@@ -218,9 +223,12 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = state
|
spawn_provider_oauth_account_state_refresh_after_update(
|
||||||
.refresh_provider_oauth_account_state_after_update(&provider, &persisted_key.id)
|
state.cloned_app(),
|
||||||
.await;
|
provider.clone(),
|
||||||
|
persisted_key.id.clone(),
|
||||||
|
request_proxy.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"key_id": persisted_key.id,
|
"key_id": persisted_key.id,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ pub(super) async fn execute_admin_provider_oauth_refresh(
|
|||||||
refreshed_key.encrypted_auth_config.as_deref(),
|
refreshed_key.encrypted_auth_config.as_deref(),
|
||||||
);
|
);
|
||||||
let (account_state_recheck_attempted, account_state_recheck_error) = state
|
let (account_state_recheck_attempted, account_state_recheck_error) = state
|
||||||
.refresh_provider_oauth_account_state_after_update(&provider, &key_id)
|
.refresh_provider_oauth_account_state_after_update(&provider, &key_id, None)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(RefreshDispatch::Continue(RefreshSuccessContext {
|
Ok(RefreshDispatch::Continue(RefreshSuccessContext {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use super::shared::{
|
use super::shared::{
|
||||||
coerce_json_f64, coerce_json_string, default_provider_quota_execution_timeouts,
|
build_quota_snapshot_payload, coerce_json_f64, coerce_json_string,
|
||||||
execute_provider_quota_plan, extract_execution_error_message,
|
default_provider_quota_execution_timeouts, execute_provider_quota_plan,
|
||||||
persist_provider_quota_refresh_state, quota_refresh_success_invalid_state,
|
extract_execution_error_message, persist_provider_quota_refresh_state,
|
||||||
ProviderQuotaExecutionOutcome,
|
quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::shared::payloads::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
|
use crate::handlers::admin::provider::shared::payloads::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_admin::provider::quota::parse_antigravity_usage_response;
|
use aether_admin::provider::quota::parse_antigravity_usage_response;
|
||||||
use aether_contracts::{ExecutionPlan, RequestBody};
|
use aether_contracts::{ExecutionPlan, ProxySnapshot, RequestBody};
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
@@ -22,6 +22,7 @@ async fn execute_antigravity_quota_plan(
|
|||||||
authorization: (String, String),
|
authorization: (String, String),
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
mut identity_headers: BTreeMap<String, String>,
|
mut identity_headers: BTreeMap<String, String>,
|
||||||
|
proxy_override: Option<&ProxySnapshot>,
|
||||||
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
|
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
|
||||||
let mut headers = std::mem::take(&mut identity_headers);
|
let mut headers = std::mem::take(&mut identity_headers);
|
||||||
headers.insert("authorization".to_string(), authorization.1);
|
headers.insert("authorization".to_string(), authorization.1);
|
||||||
@@ -32,9 +33,14 @@ async fn execute_antigravity_quota_plan(
|
|||||||
.or_insert_with(|| "antigravity".to_string());
|
.or_insert_with(|| "antigravity".to_string());
|
||||||
|
|
||||||
let body = json!({ "project": project_id });
|
let body = json!({ "project": project_id });
|
||||||
let proxy = state
|
let proxy = match proxy_override {
|
||||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
Some(proxy) => Some(proxy.clone()),
|
||||||
.await;
|
None => {
|
||||||
|
state
|
||||||
|
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
let timeouts = state
|
let timeouts = state
|
||||||
.resolve_transport_execution_timeouts(transport)
|
.resolve_transport_execution_timeouts(transport)
|
||||||
.or(Some(default_provider_quota_execution_timeouts(
|
.or(Some(default_provider_quota_execution_timeouts(
|
||||||
@@ -78,6 +84,7 @@ pub(crate) async fn refresh_antigravity_provider_quota_locally(
|
|||||||
provider: &StoredProviderCatalogProvider,
|
provider: &StoredProviderCatalogProvider,
|
||||||
endpoint: &StoredProviderCatalogEndpoint,
|
endpoint: &StoredProviderCatalogEndpoint,
|
||||||
keys: Vec<StoredProviderCatalogKey>,
|
keys: Vec<StoredProviderCatalogKey>,
|
||||||
|
proxy_override: Option<ProxySnapshot>,
|
||||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let mut success_count = 0usize;
|
let mut success_count = 0usize;
|
||||||
@@ -134,6 +141,7 @@ pub(crate) async fn refresh_antigravity_provider_quota_locally(
|
|||||||
authorization,
|
authorization,
|
||||||
&project_id,
|
&project_id,
|
||||||
identity_headers,
|
identity_headers,
|
||||||
|
proxy_override.as_ref(),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
@@ -250,6 +258,13 @@ pub(crate) async fn refresh_antigravity_provider_quota_locally(
|
|||||||
{
|
{
|
||||||
payload.insert("metadata".to_string(), metadata);
|
payload.insert("metadata".to_string(), metadata);
|
||||||
}
|
}
|
||||||
|
if let Some(quota_snapshot) = build_quota_snapshot_payload(
|
||||||
|
"antigravity",
|
||||||
|
key.status_snapshot.as_ref(),
|
||||||
|
metadata_update.as_ref(),
|
||||||
|
) {
|
||||||
|
payload.insert("quota_snapshot".to_string(), quota_snapshot);
|
||||||
|
}
|
||||||
results.push(serde_json::Value::Object(payload));
|
results.push(serde_json::Value::Object(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ use self::parse::{
|
|||||||
};
|
};
|
||||||
use self::plan::{build_codex_refresh_headers, execute_codex_quota_plan};
|
use self::plan::{build_codex_refresh_headers, execute_codex_quota_plan};
|
||||||
use super::shared::{
|
use super::shared::{
|
||||||
extract_execution_error_message, persist_provider_quota_refresh_state,
|
build_quota_snapshot_payload, extract_execution_error_message,
|
||||||
provider_auto_remove_banned_keys, quota_refresh_success_invalid_state,
|
persist_provider_quota_refresh_state, provider_auto_remove_banned_keys,
|
||||||
should_auto_remove_structured_reason, ProviderQuotaExecutionOutcome,
|
quota_refresh_success_invalid_state, should_auto_remove_structured_reason,
|
||||||
|
ProviderQuotaExecutionOutcome,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
use aether_contracts::ProxySnapshot;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
@@ -31,6 +33,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
|||||||
provider: &StoredProviderCatalogProvider,
|
provider: &StoredProviderCatalogProvider,
|
||||||
endpoint: &StoredProviderCatalogEndpoint,
|
endpoint: &StoredProviderCatalogEndpoint,
|
||||||
keys: Vec<StoredProviderCatalogKey>,
|
keys: Vec<StoredProviderCatalogKey>,
|
||||||
|
proxy_override: Option<ProxySnapshot>,
|
||||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||||
let auto_remove_abnormal_keys = provider_auto_remove_banned_keys(provider.config.as_ref());
|
let auto_remove_abnormal_keys = provider_auto_remove_banned_keys(provider.config.as_ref());
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
@@ -77,20 +80,23 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = match execute_codex_quota_plan(state, &transport, headers).await? {
|
let result =
|
||||||
ProviderQuotaExecutionOutcome::Response(result) => result,
|
match execute_codex_quota_plan(state, &transport, headers, proxy_override.as_ref())
|
||||||
ProviderQuotaExecutionOutcome::Failure(detail) => {
|
.await?
|
||||||
failed_count += 1;
|
{
|
||||||
results.push(json!({
|
ProviderQuotaExecutionOutcome::Response(result) => result,
|
||||||
"key_id": key.id,
|
ProviderQuotaExecutionOutcome::Failure(detail) => {
|
||||||
"key_name": key.name,
|
failed_count += 1;
|
||||||
"status": "error",
|
results.push(json!({
|
||||||
"message": format!("wham/usage 请求执行失败: {detail}"),
|
"key_id": key.id,
|
||||||
"status_code": 502,
|
"key_name": key.name,
|
||||||
}));
|
"status": "error",
|
||||||
continue;
|
"message": format!("wham/usage 请求执行失败: {detail}"),
|
||||||
}
|
"status_code": 502,
|
||||||
};
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let now_unix_secs = SystemTime::now()
|
let now_unix_secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -278,6 +284,13 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
|||||||
{
|
{
|
||||||
payload.insert("metadata".to_string(), metadata_update);
|
payload.insert("metadata".to_string(), metadata_update);
|
||||||
}
|
}
|
||||||
|
if let Some(quota_snapshot) = build_quota_snapshot_payload(
|
||||||
|
"codex",
|
||||||
|
key.status_snapshot.as_ref(),
|
||||||
|
metadata_update.as_ref(),
|
||||||
|
) {
|
||||||
|
payload.insert("quota_snapshot".to_string(), quota_snapshot);
|
||||||
|
}
|
||||||
if auto_removed {
|
if auto_removed {
|
||||||
payload.insert("auto_removed".to_string(), json!(true));
|
payload.insert("auto_removed".to_string(), json!(true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use super::parse::normalize_codex_plan_type;
|
|||||||
use crate::handlers::admin::provider::shared::payloads::CODEX_WHAM_USAGE_URL;
|
use crate::handlers::admin::provider::shared::payloads::CODEX_WHAM_USAGE_URL;
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_contracts::{ExecutionPlan, RequestBody};
|
use aether_contracts::{ExecutionPlan, ProxySnapshot, RequestBody};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
pub(super) fn build_codex_refresh_headers(
|
pub(super) fn build_codex_refresh_headers(
|
||||||
@@ -60,10 +60,16 @@ pub(super) async fn execute_codex_quota_plan(
|
|||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
transport: &AdminGatewayProviderTransportSnapshot,
|
transport: &AdminGatewayProviderTransportSnapshot,
|
||||||
headers: BTreeMap<String, String>,
|
headers: BTreeMap<String, String>,
|
||||||
|
proxy_override: Option<&ProxySnapshot>,
|
||||||
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
|
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
|
||||||
let proxy = state
|
let proxy = match proxy_override {
|
||||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
Some(proxy) => Some(proxy.clone()),
|
||||||
.await;
|
None => {
|
||||||
|
state
|
||||||
|
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
let timeouts = state
|
let timeouts = state
|
||||||
.resolve_transport_execution_timeouts(transport)
|
.resolve_transport_execution_timeouts(transport)
|
||||||
.or(Some(default_provider_quota_execution_timeouts(
|
.or(Some(default_provider_quota_execution_timeouts(
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ mod plan;
|
|||||||
use self::parse::parse_kiro_usage_response;
|
use self::parse::parse_kiro_usage_response;
|
||||||
use self::plan::execute_kiro_quota_plan;
|
use self::plan::execute_kiro_quota_plan;
|
||||||
use super::shared::{
|
use super::shared::{
|
||||||
extract_execution_error_message, persist_provider_quota_refresh_state,
|
build_quota_snapshot_payload, extract_execution_error_message,
|
||||||
quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
|
persist_provider_quota_refresh_state, quota_refresh_success_invalid_state,
|
||||||
|
ProviderQuotaExecutionOutcome,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
use aether_contracts::ProxySnapshot;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
@@ -20,6 +22,7 @@ pub(crate) async fn refresh_kiro_provider_quota_locally(
|
|||||||
provider: &StoredProviderCatalogProvider,
|
provider: &StoredProviderCatalogProvider,
|
||||||
endpoint: &StoredProviderCatalogEndpoint,
|
endpoint: &StoredProviderCatalogEndpoint,
|
||||||
keys: Vec<StoredProviderCatalogKey>,
|
keys: Vec<StoredProviderCatalogKey>,
|
||||||
|
proxy_override: Option<ProxySnapshot>,
|
||||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let mut success_count = 0usize;
|
let mut success_count = 0usize;
|
||||||
@@ -57,20 +60,22 @@ pub(crate) async fn refresh_kiro_provider_quota_locally(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = match execute_kiro_quota_plan(state, &transport, &auth).await? {
|
let result =
|
||||||
ProviderQuotaExecutionOutcome::Response(result) => result,
|
match execute_kiro_quota_plan(state, &transport, &auth, proxy_override.as_ref()).await?
|
||||||
ProviderQuotaExecutionOutcome::Failure(detail) => {
|
{
|
||||||
failed_count += 1;
|
ProviderQuotaExecutionOutcome::Response(result) => result,
|
||||||
results.push(json!({
|
ProviderQuotaExecutionOutcome::Failure(detail) => {
|
||||||
"key_id": key.id,
|
failed_count += 1;
|
||||||
"key_name": key.name,
|
results.push(json!({
|
||||||
"status": "error",
|
"key_id": key.id,
|
||||||
"message": format!("getUsageLimits 请求执行失败: {detail}"),
|
"key_name": key.name,
|
||||||
"status_code": 502,
|
"status": "error",
|
||||||
}));
|
"message": format!("getUsageLimits 请求执行失败: {detail}"),
|
||||||
continue;
|
"status_code": 502,
|
||||||
}
|
}));
|
||||||
};
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let now_unix_secs = SystemTime::now()
|
let now_unix_secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -93,7 +98,25 @@ pub(crate) async fn refresh_kiro_provider_quota_locally(
|
|||||||
metadata_update = parse_kiro_usage_response(body_json, now_unix_secs)
|
metadata_update = parse_kiro_usage_response(body_json, now_unix_secs)
|
||||||
.map(|metadata| json!({ "kiro": metadata }));
|
.map(|metadata| json!({ "kiro": metadata }));
|
||||||
if metadata_update.is_some() {
|
if metadata_update.is_some() {
|
||||||
let auth_config_json = auth.auth_config.to_json_value().to_string();
|
let mut auth_config_object = transport
|
||||||
|
.key
|
||||||
|
.decrypted_auth_config
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
|
||||||
|
.and_then(|value| value.as_object().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(refreshed_auth_config) =
|
||||||
|
auth.auth_config.to_json_value().as_object()
|
||||||
|
{
|
||||||
|
for (key, value) in refreshed_auth_config {
|
||||||
|
auth_config_object.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth_config_object
|
||||||
|
.entry("provider_type".to_string())
|
||||||
|
.or_insert_with(|| json!("kiro"));
|
||||||
|
let auth_config_json =
|
||||||
|
serde_json::Value::Object(auth_config_object).to_string();
|
||||||
if let Some(auth_config_json) =
|
if let Some(auth_config_json) =
|
||||||
state.encrypt_catalog_secret_with_fallbacks(auth_config_json.as_str())
|
state.encrypt_catalog_secret_with_fallbacks(auth_config_json.as_str())
|
||||||
{
|
{
|
||||||
@@ -185,6 +208,13 @@ pub(crate) async fn refresh_kiro_provider_quota_locally(
|
|||||||
{
|
{
|
||||||
payload.insert("metadata".to_string(), metadata);
|
payload.insert("metadata".to_string(), metadata);
|
||||||
}
|
}
|
||||||
|
if let Some(quota_snapshot) = build_quota_snapshot_payload(
|
||||||
|
"kiro",
|
||||||
|
key.status_snapshot.as_ref(),
|
||||||
|
metadata_update.as_ref(),
|
||||||
|
) {
|
||||||
|
payload.insert("quota_snapshot".to_string(), quota_snapshot);
|
||||||
|
}
|
||||||
results.push(serde_json::Value::Object(payload));
|
results.push(serde_json::Value::Object(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::handlers::admin::request::{
|
|||||||
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminKiroRequestAuth,
|
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminKiroRequestAuth,
|
||||||
};
|
};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_contracts::{ExecutionPlan, RequestBody};
|
use aether_contracts::{ExecutionPlan, ProxySnapshot, RequestBody};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -66,10 +66,16 @@ pub(super) async fn execute_kiro_quota_plan(
|
|||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
transport: &AdminGatewayProviderTransportSnapshot,
|
transport: &AdminGatewayProviderTransportSnapshot,
|
||||||
auth: &AdminKiroRequestAuth,
|
auth: &AdminKiroRequestAuth,
|
||||||
|
proxy_override: Option<&ProxySnapshot>,
|
||||||
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
|
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
|
||||||
let proxy = state
|
let proxy = match proxy_override {
|
||||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
Some(proxy) => Some(proxy.clone()),
|
||||||
.await;
|
None => {
|
||||||
|
state
|
||||||
|
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
let timeouts = state
|
let timeouts = state
|
||||||
.resolve_transport_execution_timeouts(transport)
|
.resolve_transport_execution_timeouts(transport)
|
||||||
.or(Some(default_provider_quota_execution_timeouts(
|
.or(Some(default_provider_quota_execution_timeouts(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::handlers::admin::provider::shared::payloads::{
|
|||||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
||||||
|
use crate::handlers::shared::sync_provider_key_quota_status_snapshot;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_admin::provider::quota as admin_provider_quota_pure;
|
use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||||
use aether_contracts::{ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot};
|
use aether_contracts::{ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot};
|
||||||
@@ -89,6 +90,20 @@ pub(super) fn coerce_json_string(value: Option<&serde_json::Value>) -> Option<St
|
|||||||
admin_provider_quota_pure::coerce_json_string(value)
|
admin_provider_quota_pure::coerce_json_string(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_quota_snapshot_payload(
|
||||||
|
provider_type: &str,
|
||||||
|
current_status_snapshot: Option<&serde_json::Value>,
|
||||||
|
metadata_update: Option<&serde_json::Value>,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let updated_snapshot = sync_provider_key_quota_status_snapshot(
|
||||||
|
current_status_snapshot,
|
||||||
|
provider_type,
|
||||||
|
metadata_update,
|
||||||
|
"refresh_api",
|
||||||
|
)?;
|
||||||
|
updated_snapshot.get("quota").cloned()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn persist_provider_quota_refresh_state(
|
pub(crate) async fn persist_provider_quota_refresh_state(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
key_id: &str,
|
key_id: &str,
|
||||||
@@ -106,17 +121,31 @@ pub(crate) async fn persist_provider_quota_refresh_state(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut quota_snapshot_provider_type = None::<&str>;
|
||||||
if let Some(metadata_update) = metadata_update {
|
if let Some(metadata_update) = metadata_update {
|
||||||
latest_key.upstream_metadata = Some(merge_upstream_metadata(
|
latest_key.upstream_metadata = Some(merge_upstream_metadata(
|
||||||
latest_key.upstream_metadata.as_ref(),
|
latest_key.upstream_metadata.as_ref(),
|
||||||
metadata_update,
|
metadata_update,
|
||||||
));
|
));
|
||||||
|
quota_snapshot_provider_type = metadata_update.as_object().and_then(|object| {
|
||||||
|
["codex", "kiro", "antigravity", "gemini_cli"]
|
||||||
|
.into_iter()
|
||||||
|
.find(|provider_type| object.contains_key(*provider_type))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Some(encrypted_auth_config) = encrypted_auth_config {
|
if let Some(encrypted_auth_config) = encrypted_auth_config {
|
||||||
latest_key.encrypted_auth_config = Some(encrypted_auth_config);
|
latest_key.encrypted_auth_config = Some(encrypted_auth_config);
|
||||||
}
|
}
|
||||||
latest_key.oauth_invalid_at_unix_secs = oauth_invalid_at_unix_secs;
|
latest_key.oauth_invalid_at_unix_secs = oauth_invalid_at_unix_secs;
|
||||||
latest_key.oauth_invalid_reason = oauth_invalid_reason;
|
latest_key.oauth_invalid_reason = oauth_invalid_reason;
|
||||||
|
if let Some(provider_type) = quota_snapshot_provider_type {
|
||||||
|
latest_key.status_snapshot = sync_provider_key_quota_status_snapshot(
|
||||||
|
latest_key.status_snapshot.as_ref(),
|
||||||
|
provider_type,
|
||||||
|
latest_key.upstream_metadata.as_ref(),
|
||||||
|
"refresh_api",
|
||||||
|
);
|
||||||
|
}
|
||||||
latest_key.updated_at_unix_secs = SystemTime::now()
|
latest_key.updated_at_unix_secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use super::quota::codex::refresh_codex_provider_quota_locally;
|
|||||||
use super::quota::kiro::refresh_kiro_provider_quota_locally;
|
use super::quota::kiro::refresh_kiro_provider_quota_locally;
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||||
use crate::GatewayError;
|
use crate::{AppState, GatewayError};
|
||||||
|
use aether_contracts::ProxySnapshot;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
@@ -65,6 +66,7 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
|
|||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
provider: &StoredProviderCatalogProvider,
|
provider: &StoredProviderCatalogProvider,
|
||||||
key_id: &str,
|
key_id: &str,
|
||||||
|
proxy_override: Option<&ProxySnapshot>,
|
||||||
) -> Result<(bool, Option<String>), GatewayError> {
|
) -> Result<(bool, Option<String>), GatewayError> {
|
||||||
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
|
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
|
||||||
if !matches!(provider_type.as_str(), "codex" | "kiro" | "antigravity") {
|
if !matches!(provider_type.as_str(), "codex" | "kiro" | "antigravity") {
|
||||||
@@ -90,16 +92,37 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
|
|||||||
return Ok((false, None));
|
return Ok((false, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let proxy_override = proxy_override.cloned();
|
||||||
let payload = match provider_type.as_str() {
|
let payload = match provider_type.as_str() {
|
||||||
"codex" => {
|
"codex" => {
|
||||||
refresh_codex_provider_quota_locally(state, provider, &endpoint, vec![key]).await?
|
refresh_codex_provider_quota_locally(
|
||||||
|
state,
|
||||||
|
provider,
|
||||||
|
&endpoint,
|
||||||
|
vec![key],
|
||||||
|
proxy_override.clone(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
"kiro" => {
|
"kiro" => {
|
||||||
refresh_kiro_provider_quota_locally(state, provider, &endpoint, vec![key]).await?
|
refresh_kiro_provider_quota_locally(
|
||||||
|
state,
|
||||||
|
provider,
|
||||||
|
&endpoint,
|
||||||
|
vec![key],
|
||||||
|
proxy_override.clone(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
"antigravity" => {
|
"antigravity" => {
|
||||||
refresh_antigravity_provider_quota_locally(state, provider, &endpoint, vec![key])
|
refresh_antigravity_provider_quota_locally(
|
||||||
.await?
|
state,
|
||||||
|
provider,
|
||||||
|
&endpoint,
|
||||||
|
vec![key],
|
||||||
|
proxy_override,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -123,3 +146,20 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
|
|||||||
};
|
};
|
||||||
Ok((true, error))
|
Ok((true, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn spawn_provider_oauth_account_state_refresh_after_update(
|
||||||
|
app: AppState,
|
||||||
|
provider: StoredProviderCatalogProvider,
|
||||||
|
key_id: String,
|
||||||
|
proxy_override: Option<ProxySnapshot>,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = refresh_provider_oauth_account_state_after_update(
|
||||||
|
&AdminAppState::new(&app),
|
||||||
|
&provider,
|
||||||
|
&key_id,
|
||||||
|
proxy_override.as_ref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::handlers::admin::request::AdminAppState;
|
|||||||
use crate::handlers::admin::shared::{provider_key_status_snapshot_payload, unix_secs_to_rfc3339};
|
use crate::handlers::admin::shared::{provider_key_status_snapshot_payload, unix_secs_to_rfc3339};
|
||||||
use crate::provider_key_auth::provider_key_auth_semantics;
|
use crate::provider_key_auth::provider_key_auth_semantics;
|
||||||
use aether_admin::provider::pool as admin_provider_pool_pure;
|
use aether_admin::provider::pool as admin_provider_pool_pure;
|
||||||
|
use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -238,88 +239,135 @@ fn admin_pool_format_reset_after(seconds: f64) -> Option<String> {
|
|||||||
Some("即将重置".to_string())
|
Some("即将重置".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_pool_build_codex_account_quota(
|
fn admin_pool_quota_snapshot_matches_provider(
|
||||||
data: &serde_json::Map<String, serde_json::Value>,
|
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
provider_type: &str,
|
||||||
|
) -> bool {
|
||||||
|
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
|
match quota_snapshot
|
||||||
|
.get("provider_type")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
Some(provider_type) => provider_type.eq_ignore_ascii_case(&normalized_provider_type),
|
||||||
|
None => {
|
||||||
|
quota_snapshot
|
||||||
|
.get("code")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.is_some_and(|code| !code.trim().eq_ignore_ascii_case("unknown"))
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("updated_at")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("observed_at")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("usage_ratio")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("reset_seconds")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("windows")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.is_some_and(|windows| !windows.is_empty())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("credits")
|
||||||
|
.and_then(serde_json::Value::as_object)
|
||||||
|
.is_some_and(|credits| !credits.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_pool_quota_window<'a>(
|
||||||
|
quota_snapshot: &'a serde_json::Map<String, serde_json::Value>,
|
||||||
|
code: &str,
|
||||||
|
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||||
|
quota_snapshot
|
||||||
|
.get("windows")
|
||||||
|
.and_then(serde_json::Value::as_array)?
|
||||||
|
.iter()
|
||||||
|
.filter_map(serde_json::Value::as_object)
|
||||||
|
.find(|window| {
|
||||||
|
window
|
||||||
|
.get("code")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case(code))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_pool_quota_window_used_percent(
|
||||||
|
window: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
) -> Option<f64> {
|
||||||
|
admin_pool_json_to_f64(window.get("used_ratio"))
|
||||||
|
.map(|value| (value * 100.0).clamp(0.0, 100.0))
|
||||||
|
.or_else(|| {
|
||||||
|
admin_pool_json_to_f64(window.get("remaining_ratio"))
|
||||||
|
.map(|value| ((1.0 - value) * 100.0).clamp(0.0, 100.0))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_pool_quota_window_reset_seconds(
|
||||||
|
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
window: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
) -> Option<f64> {
|
||||||
|
if let Some(reset_at) = admin_pool_json_to_u64(window.get("reset_at")) {
|
||||||
|
return Some(reset_at.saturating_sub(now_unix_secs) as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = admin_pool_json_to_f64(window.get("reset_seconds"))?;
|
||||||
|
let observed_at_unix_secs = admin_pool_json_to_u64(quota_snapshot.get("observed_at"))
|
||||||
|
.or_else(|| admin_pool_json_to_u64(quota_snapshot.get("updated_at")));
|
||||||
|
let elapsed = observed_at_unix_secs
|
||||||
|
.map(|observed_at| now_unix_secs.saturating_sub(observed_at) as f64)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
Some((remaining - elapsed).max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_pool_codex_quota_part_from_window(
|
||||||
|
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
window_code: &str,
|
||||||
|
label: &str,
|
||||||
|
now_unix_secs: u64,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
fn codex_reset_seconds(
|
let window = admin_pool_quota_window(quota_snapshot, window_code)?;
|
||||||
data: &serde_json::Map<String, serde_json::Value>,
|
let used_percent = admin_pool_quota_window_used_percent(window)?;
|
||||||
reset_seconds_key: &str,
|
let reset_seconds =
|
||||||
reset_after_seconds_key: &str,
|
admin_pool_quota_window_reset_seconds(quota_snapshot, window, now_unix_secs);
|
||||||
reset_at_key: &str,
|
let effective_used_percent = if reset_seconds.is_some_and(|value| value <= 0.0) {
|
||||||
now_unix_secs: u64,
|
0.0
|
||||||
updated_at_unix_secs: Option<u64>,
|
} else {
|
||||||
) -> Option<f64> {
|
used_percent
|
||||||
if let Some(reset_at) = admin_pool_json_to_u64(data.get(reset_at_key)) {
|
};
|
||||||
return Some(reset_at.saturating_sub(now_unix_secs) as f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
let remaining = admin_pool_json_to_f64(data.get(reset_seconds_key))
|
let mut part = format!(
|
||||||
.or_else(|| admin_pool_json_to_f64(data.get(reset_after_seconds_key)))?;
|
"{label}剩余 {}",
|
||||||
let elapsed = updated_at_unix_secs
|
admin_pool_format_percent(100.0 - effective_used_percent)
|
||||||
.map(|updated_at| now_unix_secs.saturating_sub(updated_at) as f64)
|
);
|
||||||
.unwrap_or(0.0);
|
if admin_pool_has_quota_consumption(Some(effective_used_percent)) {
|
||||||
Some((remaining - elapsed).max(0.0))
|
if let Some(reset_text) = reset_seconds.and_then(admin_pool_format_reset_after) {
|
||||||
|
part.push_str(&format!(" ({reset_text})"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Some(part)
|
||||||
|
}
|
||||||
|
|
||||||
fn codex_effective_used_percent(used_percent: f64, reset_seconds: Option<f64>) -> f64 {
|
fn admin_pool_build_codex_account_quota_from_snapshot(
|
||||||
let normalized = used_percent.clamp(0.0, 100.0);
|
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||||
if normalized <= 1e-6 {
|
) -> Option<String> {
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
if reset_seconds.is_some_and(|value| value <= 0.0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||||
let updated_at_unix_secs = admin_pool_json_to_u64(data.get("updated_at"));
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
let primary_used_raw = admin_pool_json_to_f64(data.get("primary_used_percent"));
|
if let Some(part) =
|
||||||
if let Some(primary_used_raw) = primary_used_raw {
|
admin_pool_codex_quota_part_from_window(quota_snapshot, "weekly", "周", now_unix_secs)
|
||||||
let primary_reset_seconds = codex_reset_seconds(
|
{
|
||||||
data,
|
|
||||||
"primary_reset_seconds",
|
|
||||||
"primary_reset_after_seconds",
|
|
||||||
"primary_reset_at",
|
|
||||||
now_unix_secs,
|
|
||||||
updated_at_unix_secs,
|
|
||||||
);
|
|
||||||
let primary_used = codex_effective_used_percent(primary_used_raw, primary_reset_seconds);
|
|
||||||
let mut part = format!("周剩余 {}", admin_pool_format_percent(100.0 - primary_used));
|
|
||||||
if admin_pool_has_quota_consumption(Some(primary_used)) {
|
|
||||||
if let Some(reset_text) = primary_reset_seconds.and_then(admin_pool_format_reset_after)
|
|
||||||
{
|
|
||||||
part.push_str(&format!(" ({reset_text})"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.push(part);
|
parts.push(part);
|
||||||
}
|
}
|
||||||
|
if let Some(part) =
|
||||||
let secondary_used_raw = admin_pool_json_to_f64(data.get("secondary_used_percent"));
|
admin_pool_codex_quota_part_from_window(quota_snapshot, "5h", "5H", now_unix_secs)
|
||||||
if let Some(secondary_used_raw) = secondary_used_raw {
|
{
|
||||||
let secondary_reset_seconds = codex_reset_seconds(
|
|
||||||
data,
|
|
||||||
"secondary_reset_seconds",
|
|
||||||
"secondary_reset_after_seconds",
|
|
||||||
"secondary_reset_at",
|
|
||||||
now_unix_secs,
|
|
||||||
updated_at_unix_secs,
|
|
||||||
);
|
|
||||||
let secondary_used =
|
|
||||||
codex_effective_used_percent(secondary_used_raw, secondary_reset_seconds);
|
|
||||||
let mut part = format!(
|
|
||||||
"5H剩余 {}",
|
|
||||||
admin_pool_format_percent(100.0 - secondary_used)
|
|
||||||
);
|
|
||||||
if admin_pool_has_quota_consumption(Some(secondary_used)) {
|
|
||||||
if let Some(reset_text) =
|
|
||||||
secondary_reset_seconds.and_then(admin_pool_format_reset_after)
|
|
||||||
{
|
|
||||||
part.push_str(&format!(" ({reset_text})"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.push(part);
|
parts.push(part);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,11 +375,16 @@ fn admin_pool_build_codex_account_quota(
|
|||||||
return Some(parts.join(" | "));
|
return Some(parts.join(" | "));
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_credits = data
|
let credits = quota_snapshot
|
||||||
.get("has_credits")
|
.get("credits")
|
||||||
.and_then(serde_json::Value::as_bool)
|
.and_then(serde_json::Value::as_object);
|
||||||
|
let has_credits = credits
|
||||||
|
.and_then(|credits| credits.get("has_credits"))
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let credits_balance = admin_pool_json_to_f64(data.get("credits_balance"));
|
let credits_balance = credits
|
||||||
|
.and_then(|credits| credits.get("balance"))
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
||||||
if has_credits && credits_balance.is_some() {
|
if has_credits && credits_balance.is_some() {
|
||||||
return credits_balance.map(|value| format!("积分 {value:.2}"));
|
return credits_balance.map(|value| format!("积分 {value:.2}"));
|
||||||
}
|
}
|
||||||
@@ -342,73 +395,115 @@ fn admin_pool_build_codex_account_quota(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_pool_build_kiro_account_quota(
|
fn admin_pool_quota_windows<'a>(
|
||||||
data: &serde_json::Map<String, serde_json::Value>,
|
quota_snapshot: &'a serde_json::Map<String, serde_json::Value>,
|
||||||
|
) -> Vec<&'a serde_json::Map<String, serde_json::Value>> {
|
||||||
|
quota_snapshot
|
||||||
|
.get("windows")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.map(|windows| {
|
||||||
|
windows
|
||||||
|
.iter()
|
||||||
|
.filter_map(serde_json::Value::as_object)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_pool_build_kiro_account_quota_from_snapshot(
|
||||||
|
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if data
|
let code = quota_snapshot
|
||||||
.get("is_banned")
|
.get("code")
|
||||||
.and_then(serde_json::Value::as_bool)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or(false)
|
.map(str::trim)
|
||||||
{
|
.unwrap_or_default();
|
||||||
return Some("账号已封禁".to_string());
|
if code.eq_ignore_ascii_case("banned") {
|
||||||
|
return quota_snapshot
|
||||||
|
.get("label")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| Some("账号已封禁".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let usage_percentage = admin_pool_json_to_f64(data.get("usage_percentage"));
|
let window = admin_pool_quota_windows(quota_snapshot)
|
||||||
if let Some(usage_percentage) = usage_percentage {
|
.into_iter()
|
||||||
let remaining = 100.0 - usage_percentage;
|
.next()?;
|
||||||
let current_usage = admin_pool_json_to_f64(data.get("current_usage"));
|
let used_ratio = admin_pool_json_to_f64(window.get("used_ratio"));
|
||||||
let usage_limit = admin_pool_json_to_f64(data.get("usage_limit"));
|
let remaining_ratio = admin_pool_json_to_f64(window.get("remaining_ratio"))
|
||||||
if let (Some(current_usage), Some(usage_limit)) = (current_usage, usage_limit) {
|
.or_else(|| used_ratio.map(|value| (1.0 - value).max(0.0)));
|
||||||
if usage_limit > 0.0 {
|
let used_value = admin_pool_json_to_f64(window.get("used_value"));
|
||||||
|
let remaining_value = admin_pool_json_to_f64(window.get("remaining_value"));
|
||||||
|
let limit_value = admin_pool_json_to_f64(window.get("limit_value"));
|
||||||
|
|
||||||
|
if let (Some(remaining_value), Some(limit_value)) = (remaining_value, limit_value) {
|
||||||
|
if limit_value > 0.0 && remaining_value <= 0.0 {
|
||||||
|
return Some(format!(
|
||||||
|
"剩余 {}/{}",
|
||||||
|
admin_pool_format_quota_value(remaining_value),
|
||||||
|
admin_pool_format_quota_value(limit_value),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(remaining_ratio) = remaining_ratio {
|
||||||
|
let remaining_percent = (remaining_ratio * 100.0).clamp(0.0, 100.0);
|
||||||
|
if let (Some(used_value), Some(limit_value)) = (used_value, limit_value) {
|
||||||
|
if limit_value > 0.0 {
|
||||||
return Some(format!(
|
return Some(format!(
|
||||||
"剩余 {} ({}/{})",
|
"剩余 {} ({}/{})",
|
||||||
admin_pool_format_percent(remaining),
|
admin_pool_format_percent(remaining_percent),
|
||||||
admin_pool_format_quota_value(current_usage),
|
admin_pool_format_quota_value(used_value),
|
||||||
admin_pool_format_quota_value(usage_limit),
|
admin_pool_format_quota_value(limit_value),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Some(format!("剩余 {}", admin_pool_format_percent(remaining)));
|
return Some(format!(
|
||||||
|
"剩余 {}",
|
||||||
|
admin_pool_format_percent(remaining_percent)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let remaining = admin_pool_json_to_f64(data.get("remaining"));
|
match (remaining_value, limit_value) {
|
||||||
let usage_limit = admin_pool_json_to_f64(data.get("usage_limit"));
|
(Some(remaining_value), Some(limit_value)) if limit_value > 0.0 => Some(format!(
|
||||||
match (remaining, usage_limit) {
|
|
||||||
(Some(remaining), Some(usage_limit)) if usage_limit > 0.0 => Some(format!(
|
|
||||||
"剩余 {}/{}",
|
"剩余 {}/{}",
|
||||||
admin_pool_format_quota_value(remaining),
|
admin_pool_format_quota_value(remaining_value),
|
||||||
admin_pool_format_quota_value(usage_limit),
|
admin_pool_format_quota_value(limit_value),
|
||||||
)),
|
)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_pool_quota_by_model(
|
fn admin_pool_build_antigravity_account_quota_from_snapshot(
|
||||||
data: &serde_json::Map<String, serde_json::Value>,
|
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
|
||||||
data.get("quota_by_model")?.as_object()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn admin_pool_build_antigravity_account_quota(
|
|
||||||
data: &serde_json::Map<String, serde_json::Value>,
|
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if data
|
if quota_snapshot
|
||||||
.get("is_forbidden")
|
.get("code")
|
||||||
.and_then(serde_json::Value::as_bool)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or(false)
|
.is_some_and(|code| code.eq_ignore_ascii_case("forbidden"))
|
||||||
{
|
{
|
||||||
return Some("访问受限".to_string());
|
return quota_snapshot
|
||||||
|
.get("label")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| Some("访问受限".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let remaining_list = admin_pool_quota_by_model(data)?
|
let remaining_list = admin_pool_quota_windows(quota_snapshot)
|
||||||
.values()
|
.into_iter()
|
||||||
.filter_map(serde_json::Value::as_object)
|
.filter(|window| {
|
||||||
.filter_map(|item| {
|
window
|
||||||
let used_percent = admin_pool_json_to_f64(item.get("used_percent")).or_else(|| {
|
.get("scope")
|
||||||
admin_pool_json_to_f64(item.get("remaining_fraction"))
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(|value| (1.0 - value) * 100.0)
|
.is_some_and(|scope| scope.eq_ignore_ascii_case("model"))
|
||||||
})?;
|
})
|
||||||
Some((100.0 - used_percent).clamp(0.0, 100.0))
|
.filter_map(|window| {
|
||||||
|
admin_pool_json_to_f64(window.get("remaining_ratio"))
|
||||||
|
.map(|value| (value * 100.0).clamp(0.0, 100.0))
|
||||||
|
.or_else(|| {
|
||||||
|
admin_pool_json_to_f64(window.get("used_ratio"))
|
||||||
|
.map(|value| ((1.0 - value) * 100.0).clamp(0.0, 100.0))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -427,41 +522,41 @@ fn admin_pool_build_antigravity_account_quota(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_pool_gemini_reset_at(item: &serde_json::Map<String, serde_json::Value>) -> Option<i64> {
|
fn admin_pool_build_gemini_cli_account_quota_from_snapshot(
|
||||||
let reset_at = admin_pool_json_to_u64(item.get("reset_at"))?;
|
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||||
Some(reset_at as i64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn admin_pool_gemini_model_exhausted(item: &serde_json::Map<String, serde_json::Value>) -> bool {
|
|
||||||
if item
|
|
||||||
.get("is_exhausted")
|
|
||||||
.and_then(serde_json::Value::as_bool)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if admin_pool_json_to_f64(item.get("remaining_fraction")).is_some_and(|value| value <= 0.0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
admin_pool_json_to_f64(item.get("used_percent")).is_some_and(|value| value >= 100.0 - 1e-6)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn admin_pool_build_gemini_cli_account_quota(
|
|
||||||
data: &serde_json::Map<String, serde_json::Value>,
|
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
let mut active = admin_pool_quota_by_model(data)?
|
let mut active = admin_pool_quota_windows(quota_snapshot)
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter_map(|(model_name, item)| {
|
.filter(|window| {
|
||||||
let item = item.as_object()?;
|
window
|
||||||
if !admin_pool_gemini_model_exhausted(item) {
|
.get("scope")
|
||||||
return None;
|
.and_then(serde_json::Value::as_str)
|
||||||
}
|
.is_some_and(|scope| scope.eq_ignore_ascii_case("model"))
|
||||||
let reset_at = admin_pool_gemini_reset_at(item);
|
})
|
||||||
|
.filter(|window| {
|
||||||
|
window
|
||||||
|
.get("is_exhausted")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||||
|
.or_else(|| {
|
||||||
|
admin_pool_json_to_f64(window.get("used_ratio"))
|
||||||
|
.map(|value| value >= 1.0 - 1e-6)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.filter_map(|window| {
|
||||||
|
let reset_at = admin_pool_json_to_u64(window.get("reset_at")).map(|value| value as i64);
|
||||||
if reset_at.is_some_and(|value| value <= now) {
|
if reset_at.is_some_and(|value| value <= now) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some((model_name.as_str(), reset_at))
|
let label = window
|
||||||
|
.get("label")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.or_else(|| window.get("model").and_then(serde_json::Value::as_str))
|
||||||
|
.unwrap_or("模型");
|
||||||
|
Some((label.to_string(), reset_at))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -470,10 +565,10 @@ fn admin_pool_build_gemini_cli_account_quota(
|
|||||||
}
|
}
|
||||||
|
|
||||||
active.sort_by_key(|(_, reset_at)| reset_at.unwrap_or(i64::MAX));
|
active.sort_by_key(|(_, reset_at)| reset_at.unwrap_or(i64::MAX));
|
||||||
let (first_model, first_reset_at) = active[0];
|
let (first_model, first_reset_at) = &active[0];
|
||||||
if active.len() == 1 {
|
if active.len() == 1 {
|
||||||
if let Some(reset_at) = first_reset_at {
|
if let Some(reset_at) = first_reset_at {
|
||||||
if let Some(reset_text) = admin_pool_format_reset_after((reset_at - now) as f64) {
|
if let Some(reset_text) = admin_pool_format_reset_after((*reset_at - now) as f64) {
|
||||||
return Some(format!("{first_model} 冷却中 ({reset_text})"));
|
return Some(format!("{first_model} 冷却中 ({reset_text})"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,7 +576,7 @@ fn admin_pool_build_gemini_cli_account_quota(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(reset_at) = first_reset_at {
|
if let Some(reset_at) = first_reset_at {
|
||||||
if let Some(reset_text) = admin_pool_format_reset_after((reset_at - now) as f64) {
|
if let Some(reset_text) = admin_pool_format_reset_after((*reset_at - now) as f64) {
|
||||||
return Some(format!(
|
return Some(format!(
|
||||||
"{} 个模型冷却中(最早 {reset_text})",
|
"{} 个模型冷却中(最早 {reset_text})",
|
||||||
active.len()
|
active.len()
|
||||||
@@ -493,21 +588,46 @@ fn admin_pool_build_gemini_cli_account_quota(
|
|||||||
|
|
||||||
fn admin_pool_build_account_quota(
|
fn admin_pool_build_account_quota(
|
||||||
provider_type: &str,
|
provider_type: &str,
|
||||||
upstream_metadata: Option<&serde_json::Value>,
|
quota_snapshot: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
let upstream_metadata = upstream_metadata?.as_object()?;
|
let quota_snapshot = quota_snapshot.filter(|quota_snapshot| {
|
||||||
let data = upstream_metadata
|
admin_pool_quota_snapshot_matches_provider(quota_snapshot, &normalized_provider_type)
|
||||||
.get(&normalized_provider_type)?
|
})?;
|
||||||
.as_object()?;
|
|
||||||
|
|
||||||
match normalized_provider_type.as_str() {
|
match normalized_provider_type.as_str() {
|
||||||
"codex" => admin_pool_build_codex_account_quota(data),
|
"codex" => {
|
||||||
"kiro" => admin_pool_build_kiro_account_quota(data),
|
if let Some(account_quota) =
|
||||||
"antigravity" => admin_pool_build_antigravity_account_quota(data),
|
admin_pool_build_codex_account_quota_from_snapshot(quota_snapshot)
|
||||||
"gemini_cli" => admin_pool_build_gemini_cli_account_quota(data),
|
{
|
||||||
_ => None,
|
return Some(account_quota);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"kiro" => {
|
||||||
|
if let Some(account_quota) =
|
||||||
|
admin_pool_build_kiro_account_quota_from_snapshot(quota_snapshot)
|
||||||
|
{
|
||||||
|
return Some(account_quota);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"antigravity" => {
|
||||||
|
if let Some(account_quota) =
|
||||||
|
admin_pool_build_antigravity_account_quota_from_snapshot(quota_snapshot)
|
||||||
|
{
|
||||||
|
return Some(account_quota);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gemini_cli" => {
|
||||||
|
if let Some(account_quota) =
|
||||||
|
admin_pool_build_gemini_cli_account_quota_from_snapshot(quota_snapshot)
|
||||||
|
{
|
||||||
|
return Some(account_quota);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_pool_health_score(key: &StoredProviderCatalogKey) -> f64 {
|
fn admin_pool_health_score(key: &StoredProviderCatalogKey) -> f64 {
|
||||||
@@ -668,7 +788,7 @@ pub(super) fn build_admin_pool_key_payload(
|
|||||||
admin_pool_derive_oauth_expires_at(provider_type, key, auth_config.as_ref());
|
admin_pool_derive_oauth_expires_at(provider_type, key, auth_config.as_ref());
|
||||||
let oauth_plan_type =
|
let oauth_plan_type =
|
||||||
admin_pool_derive_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
admin_pool_derive_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
||||||
let status_snapshot = provider_key_status_snapshot_payload(key);
|
let status_snapshot = provider_key_status_snapshot_payload(key, provider_type);
|
||||||
let account_snapshot = status_snapshot
|
let account_snapshot = status_snapshot
|
||||||
.get("account")
|
.get("account")
|
||||||
.and_then(serde_json::Value::as_object);
|
.and_then(serde_json::Value::as_object);
|
||||||
@@ -680,6 +800,7 @@ pub(super) fn build_admin_pool_key_payload(
|
|||||||
.and_then(serde_json::Value::as_object);
|
.and_then(serde_json::Value::as_object);
|
||||||
let quota_updated_at =
|
let quota_updated_at =
|
||||||
admin_pool_json_to_u64(quota_snapshot.and_then(|item| item.get("updated_at")));
|
admin_pool_json_to_u64(quota_snapshot.and_then(|item| item.get("updated_at")));
|
||||||
|
let account_quota = admin_pool_build_account_quota(provider_type, quota_snapshot);
|
||||||
let oauth_invalid_at = if auth_semantics.can_show_oauth_metadata() {
|
let oauth_invalid_at = if auth_semantics.can_show_oauth_metadata() {
|
||||||
admin_pool_json_to_u64(oauth_snapshot.and_then(|item| item.get("invalid_at")))
|
admin_pool_json_to_u64(oauth_snapshot.and_then(|item| item.get("invalid_at")))
|
||||||
.or(key.oauth_invalid_at_unix_secs)
|
.or(key.oauth_invalid_at_unix_secs)
|
||||||
@@ -851,13 +972,7 @@ pub(super) fn build_admin_pool_key_payload(
|
|||||||
);
|
);
|
||||||
payload.insert("proxy".to_string(), json!(key.proxy.clone()));
|
payload.insert("proxy".to_string(), json!(key.proxy.clone()));
|
||||||
payload.insert("fingerprint".to_string(), json!(key.fingerprint.clone()));
|
payload.insert("fingerprint".to_string(), json!(key.fingerprint.clone()));
|
||||||
payload.insert(
|
payload.insert("account_quota".to_string(), json!(account_quota));
|
||||||
"account_quota".to_string(),
|
|
||||||
json!(admin_pool_build_account_quota(
|
|
||||||
provider_type,
|
|
||||||
key.upstream_metadata.as_ref(),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
payload.insert("cooldown_reason".to_string(), json!(cooldown_reason));
|
payload.insert("cooldown_reason".to_string(), json!(cooldown_reason));
|
||||||
payload.insert(
|
payload.insert(
|
||||||
"cooldown_ttl_seconds".to_string(),
|
"cooldown_ttl_seconds".to_string(),
|
||||||
|
|||||||
@@ -514,11 +514,13 @@ impl<'a> AdminAppState<'a> {
|
|||||||
&self,
|
&self,
|
||||||
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
|
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
|
||||||
key_id: &str,
|
key_id: &str,
|
||||||
|
proxy_override: Option<&ProxySnapshot>,
|
||||||
) -> Result<(bool, Option<String>), GatewayError> {
|
) -> Result<(bool, Option<String>), GatewayError> {
|
||||||
crate::handlers::admin::provider::oauth::runtime::refresh_provider_oauth_account_state_after_update(
|
crate::handlers::admin::provider::oauth::runtime::refresh_provider_oauth_account_state_after_update(
|
||||||
self,
|
self,
|
||||||
provider,
|
provider,
|
||||||
key_id,
|
key_id,
|
||||||
|
proxy_override,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339};
|
use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339};
|
||||||
use crate::provider_key_auth::provider_key_auth_semantics;
|
use crate::provider_key_auth::provider_key_auth_semantics;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||||
use aether_crypto::{decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext};
|
use aether_crypto::{decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext};
|
||||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||||
use serde_json::json;
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
pub(crate) fn provider_catalog_key_supports_format(
|
pub(crate) fn provider_catalog_key_supports_format(
|
||||||
key: &StoredProviderCatalogKey,
|
key: &StoredProviderCatalogKey,
|
||||||
@@ -153,13 +154,683 @@ pub(crate) fn default_provider_key_status_snapshot() -> serde_json::Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn provider_key_status_snapshot_object(
|
||||||
|
status_snapshot: Option<&Value>,
|
||||||
|
) -> Option<Map<String, Value>> {
|
||||||
|
status_snapshot.and_then(|value| match value {
|
||||||
|
Value::Object(object) => Some(object.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_quota_metadata_bucket<'a>(
|
||||||
|
upstream_metadata: Option<&'a Value>,
|
||||||
|
provider_type: &str,
|
||||||
|
) -> Option<&'a Map<String, Value>> {
|
||||||
|
upstream_metadata
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|metadata| metadata.get(&provider_type.trim().to_ascii_lowercase()))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_quota_timestamp_unix_secs(value: Option<&Value>) -> Option<u64> {
|
||||||
|
let mut parsed = match value {
|
||||||
|
Some(Value::Number(number)) => number.as_f64(),
|
||||||
|
Some(Value::String(text)) => text.trim().parse::<f64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
if !parsed.is_finite() || parsed <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if parsed > 1_000_000_000_000.0 {
|
||||||
|
parsed /= 1000.0;
|
||||||
|
}
|
||||||
|
Some(parsed.floor() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_quota_model_bucket(metadata: &Map<String, Value>) -> Option<&Map<String, Value>> {
|
||||||
|
metadata
|
||||||
|
.get("quota_by_model")
|
||||||
|
.or_else(|| metadata.get("models"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota_window_reset_seconds(
|
||||||
|
observed_at_unix_secs: Option<u64>,
|
||||||
|
reset_at_unix_secs: Option<u64>,
|
||||||
|
) -> Option<u64> {
|
||||||
|
observed_at_unix_secs
|
||||||
|
.zip(reset_at_unix_secs)
|
||||||
|
.map(|(observed_at, reset_at)| reset_at.saturating_sub(observed_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_quota_window_snapshot(
|
||||||
|
model_name: &str,
|
||||||
|
item: &Map<String, Value>,
|
||||||
|
observed_at_unix_secs: Option<u64>,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let used_ratio = item
|
||||||
|
.get("used_percent")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
||||||
|
.map(|value| (value / 100.0).clamp(0.0, 1.0))
|
||||||
|
.or_else(|| {
|
||||||
|
item.get("remaining_fraction")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
||||||
|
.map(|value| (1.0 - value.clamp(0.0, 1.0)).clamp(0.0, 1.0))
|
||||||
|
});
|
||||||
|
let remaining_ratio = item
|
||||||
|
.get("remaining_fraction")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
||||||
|
.map(|value| value.clamp(0.0, 1.0))
|
||||||
|
.or_else(|| used_ratio.map(|value| (1.0 - value).max(0.0)));
|
||||||
|
let reset_at = provider_quota_timestamp_unix_secs(
|
||||||
|
item.get("reset_at").or_else(|| item.get("next_reset_at")),
|
||||||
|
);
|
||||||
|
let reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, reset_at);
|
||||||
|
let is_exhausted = item
|
||||||
|
.get("is_exhausted")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||||
|
.or_else(|| used_ratio.map(|value| value >= 1.0 - 1e-6));
|
||||||
|
|
||||||
|
if used_ratio.is_none()
|
||||||
|
&& remaining_ratio.is_none()
|
||||||
|
&& reset_at.is_none()
|
||||||
|
&& reset_seconds.is_none()
|
||||||
|
&& is_exhausted.is_none()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window = Map::new();
|
||||||
|
let label = item
|
||||||
|
.get("display_name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(model_name);
|
||||||
|
window.insert("code".to_string(), json!(format!("model:{model_name}")));
|
||||||
|
window.insert("label".to_string(), json!(label));
|
||||||
|
window.insert("scope".to_string(), json!("model"));
|
||||||
|
window.insert("unit".to_string(), json!("percent"));
|
||||||
|
window.insert("model".to_string(), json!(model_name));
|
||||||
|
window.insert("used_ratio".to_string(), json!(used_ratio));
|
||||||
|
window.insert("remaining_ratio".to_string(), json!(remaining_ratio));
|
||||||
|
window.insert("reset_at".to_string(), json!(reset_at));
|
||||||
|
window.insert("reset_seconds".to_string(), json!(reset_seconds));
|
||||||
|
window.insert("is_exhausted".to_string(), json!(is_exhausted));
|
||||||
|
Some(Value::Object(window))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota_windows_usage_ratio(windows: &[Value]) -> Option<f64> {
|
||||||
|
windows
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_object)
|
||||||
|
.filter_map(|window| window.get("used_ratio"))
|
||||||
|
.filter_map(Value::as_f64)
|
||||||
|
.max_by(f64::total_cmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota_windows_min_reset_seconds(windows: &[Value]) -> Option<u64> {
|
||||||
|
windows
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_object)
|
||||||
|
.filter_map(|window| window.get("reset_seconds"))
|
||||||
|
.filter_map(admin_provider_quota_pure::coerce_json_u64)
|
||||||
|
.min()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota_windows_all_exhausted(windows: &[Value]) -> bool {
|
||||||
|
let mut total = 0usize;
|
||||||
|
let mut exhausted = 0usize;
|
||||||
|
for window in windows.iter().filter_map(Value::as_object) {
|
||||||
|
total += 1;
|
||||||
|
let is_exhausted = window
|
||||||
|
.get("is_exhausted")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||||
|
.or_else(|| {
|
||||||
|
window
|
||||||
|
.get("used_ratio")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.map(|value| value >= 1.0 - 1e-6)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if is_exhausted {
|
||||||
|
exhausted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total > 0 && exhausted == total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_quota_window_snapshot(
|
||||||
|
metadata: &Map<String, Value>,
|
||||||
|
prefix: &str,
|
||||||
|
code: &str,
|
||||||
|
label: &str,
|
||||||
|
observed_at_unix_secs: Option<u64>,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let used_percent_key = format!("{prefix}_used_percent");
|
||||||
|
let reset_seconds_key = format!("{prefix}_reset_seconds");
|
||||||
|
let reset_after_seconds_key = format!("{prefix}_reset_after_seconds");
|
||||||
|
let reset_at_key = format!("{prefix}_reset_at");
|
||||||
|
let window_minutes_key = format!("{prefix}_window_minutes");
|
||||||
|
|
||||||
|
let used_percent = metadata
|
||||||
|
.get(&used_percent_key)
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
||||||
|
let reset_at = metadata
|
||||||
|
.get(&reset_at_key)
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||||
|
let reset_seconds = metadata
|
||||||
|
.get(&reset_seconds_key)
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
||||||
|
.or_else(|| {
|
||||||
|
metadata
|
||||||
|
.get(&reset_after_seconds_key)
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
observed_at_unix_secs
|
||||||
|
.zip(reset_at)
|
||||||
|
.map(|(observed_at, reset_at)| reset_at.saturating_sub(observed_at))
|
||||||
|
});
|
||||||
|
let window_minutes = metadata
|
||||||
|
.get(&window_minutes_key)
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||||
|
|
||||||
|
if used_percent.is_none()
|
||||||
|
&& reset_at.is_none()
|
||||||
|
&& reset_seconds.is_none()
|
||||||
|
&& window_minutes.is_none()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let used_ratio = used_percent.map(|value| (value / 100.0).clamp(0.0, 1.0));
|
||||||
|
let remaining_ratio = used_ratio.map(|value| (1.0 - value).max(0.0));
|
||||||
|
|
||||||
|
let mut window = Map::new();
|
||||||
|
window.insert("code".to_string(), json!(code));
|
||||||
|
window.insert("label".to_string(), json!(label));
|
||||||
|
window.insert("scope".to_string(), json!("account"));
|
||||||
|
window.insert("unit".to_string(), json!("percent"));
|
||||||
|
window.insert("used_ratio".to_string(), json!(used_ratio));
|
||||||
|
window.insert("remaining_ratio".to_string(), json!(remaining_ratio));
|
||||||
|
window.insert("reset_at".to_string(), json!(reset_at));
|
||||||
|
window.insert("reset_seconds".to_string(), json!(reset_seconds));
|
||||||
|
window.insert("window_minutes".to_string(), json!(window_minutes));
|
||||||
|
Some(Value::Object(window))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_codex_quota_status_snapshot(
|
||||||
|
upstream_metadata: Option<&Value>,
|
||||||
|
source: &str,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "codex")?;
|
||||||
|
let observed_at_unix_secs = metadata
|
||||||
|
.get("updated_at")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||||
|
let plan_type = metadata
|
||||||
|
.get("plan_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|value| admin_provider_quota_pure::normalize_codex_plan_type(Some(value)));
|
||||||
|
let credits_has_credits = metadata
|
||||||
|
.get("has_credits")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool);
|
||||||
|
let credits_balance = metadata
|
||||||
|
.get("credits_balance")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
||||||
|
let credits_unlimited = metadata
|
||||||
|
.get("credits_unlimited")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool);
|
||||||
|
|
||||||
|
let windows = [
|
||||||
|
codex_quota_window_snapshot(metadata, "primary", "weekly", "周", observed_at_unix_secs),
|
||||||
|
codex_quota_window_snapshot(metadata, "secondary", "5h", "5H", observed_at_unix_secs),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if windows.is_empty()
|
||||||
|
&& plan_type.is_none()
|
||||||
|
&& credits_has_credits.is_none()
|
||||||
|
&& credits_balance.is_none()
|
||||||
|
&& credits_unlimited.is_none()
|
||||||
|
&& observed_at_unix_secs.is_none()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage_ratio = windows
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_object)
|
||||||
|
.filter_map(|window| window.get("used_ratio"))
|
||||||
|
.filter_map(Value::as_f64)
|
||||||
|
.max_by(f64::total_cmp);
|
||||||
|
let reset_seconds = windows
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_object)
|
||||||
|
.filter_map(|window| window.get("reset_seconds"))
|
||||||
|
.filter_map(admin_provider_quota_pure::coerce_json_u64)
|
||||||
|
.min();
|
||||||
|
let exhausted_by_credits =
|
||||||
|
credits_unlimited != Some(true) && credits_has_credits == Some(false);
|
||||||
|
let exhausted_by_window = usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
|
||||||
|
let exhausted = exhausted_by_credits || exhausted_by_window;
|
||||||
|
|
||||||
|
let mut credits = Map::new();
|
||||||
|
if let Some(value) = credits_has_credits {
|
||||||
|
credits.insert("has_credits".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
if let Some(value) = credits_balance {
|
||||||
|
credits.insert("balance".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
if let Some(value) = credits_unlimited {
|
||||||
|
credits.insert("unlimited".to_string(), json!(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = if exhausted_by_credits {
|
||||||
|
Some("无可用积分")
|
||||||
|
} else if exhausted_by_window {
|
||||||
|
Some("额度窗口已耗尽")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(json!({
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "codex",
|
||||||
|
"code": if exhausted { "exhausted" } else { "ok" },
|
||||||
|
"label": if exhausted { Some("额度耗尽") } else { None::<&str> },
|
||||||
|
"reason": reason,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": source,
|
||||||
|
"observed_at": observed_at_unix_secs,
|
||||||
|
"exhausted": exhausted,
|
||||||
|
"usage_ratio": usage_ratio,
|
||||||
|
"updated_at": observed_at_unix_secs,
|
||||||
|
"reset_seconds": reset_seconds,
|
||||||
|
"plan_type": plan_type,
|
||||||
|
"credits": if credits.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
Value::Object(credits)
|
||||||
|
},
|
||||||
|
"windows": windows,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_kiro_quota_status_snapshot(
|
||||||
|
upstream_metadata: Option<&Value>,
|
||||||
|
source: &str,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "kiro")?;
|
||||||
|
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
|
||||||
|
let usage_limit = metadata
|
||||||
|
.get("usage_limit")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
||||||
|
let current_usage = metadata
|
||||||
|
.get("current_usage")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
||||||
|
let remaining = metadata
|
||||||
|
.get("remaining")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
||||||
|
let usage_ratio = metadata
|
||||||
|
.get("usage_percentage")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
||||||
|
.map(|value| (value / 100.0).clamp(0.0, 1.0))
|
||||||
|
.or_else(|| {
|
||||||
|
current_usage
|
||||||
|
.zip(usage_limit)
|
||||||
|
.and_then(|(current_usage, usage_limit)| {
|
||||||
|
(usage_limit > 0.0).then_some((current_usage / usage_limit).clamp(0.0, 1.0))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let remaining_ratio = usage_ratio.map(|value| (1.0 - value).max(0.0));
|
||||||
|
let next_reset_at = provider_quota_timestamp_unix_secs(metadata.get("next_reset_at"));
|
||||||
|
let reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, next_reset_at);
|
||||||
|
let plan_type = metadata
|
||||||
|
.get("subscription_title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let is_banned = metadata
|
||||||
|
.get("is_banned")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||||
|
== Some(true);
|
||||||
|
let ban_reason = metadata
|
||||||
|
.get("ban_reason")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
|
||||||
|
let mut windows = Vec::new();
|
||||||
|
if usage_ratio.is_some()
|
||||||
|
|| remaining.is_some()
|
||||||
|
|| usage_limit.is_some()
|
||||||
|
|| current_usage.is_some()
|
||||||
|
|| next_reset_at.is_some()
|
||||||
|
{
|
||||||
|
windows.push(json!({
|
||||||
|
"code": "usage",
|
||||||
|
"label": "额度",
|
||||||
|
"scope": "account",
|
||||||
|
"unit": "count",
|
||||||
|
"used_ratio": usage_ratio,
|
||||||
|
"remaining_ratio": remaining_ratio,
|
||||||
|
"used_value": current_usage,
|
||||||
|
"remaining_value": remaining,
|
||||||
|
"limit_value": usage_limit,
|
||||||
|
"reset_at": next_reset_at,
|
||||||
|
"reset_seconds": reset_seconds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if windows.is_empty() && plan_type.is_none() && observed_at_unix_secs.is_none() && !is_banned {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exhausted = !is_banned
|
||||||
|
&& (remaining.is_some_and(|value| value <= 0.0)
|
||||||
|
|| usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6));
|
||||||
|
let reason = if is_banned {
|
||||||
|
ban_reason
|
||||||
|
} else if exhausted {
|
||||||
|
Some("额度已耗尽".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let label = if is_banned {
|
||||||
|
Some("账号已封禁")
|
||||||
|
} else if exhausted {
|
||||||
|
Some("额度耗尽")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let code = if is_banned {
|
||||||
|
"banned"
|
||||||
|
} else if exhausted {
|
||||||
|
"exhausted"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(json!({
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "kiro",
|
||||||
|
"code": code,
|
||||||
|
"label": label,
|
||||||
|
"reason": reason,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": source,
|
||||||
|
"observed_at": observed_at_unix_secs,
|
||||||
|
"exhausted": exhausted,
|
||||||
|
"usage_ratio": usage_ratio,
|
||||||
|
"updated_at": observed_at_unix_secs,
|
||||||
|
"reset_seconds": reset_seconds,
|
||||||
|
"plan_type": plan_type,
|
||||||
|
"windows": windows,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_antigravity_quota_status_snapshot(
|
||||||
|
upstream_metadata: Option<&Value>,
|
||||||
|
source: &str,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "antigravity")?;
|
||||||
|
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
|
||||||
|
let is_forbidden = metadata
|
||||||
|
.get("is_forbidden")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||||
|
== Some(true);
|
||||||
|
let forbidden_reason = metadata
|
||||||
|
.get("forbidden_reason")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let windows = provider_quota_model_bucket(metadata)
|
||||||
|
.map(|models| {
|
||||||
|
models
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(model_name, item)| {
|
||||||
|
model_quota_window_snapshot(
|
||||||
|
model_name,
|
||||||
|
item.as_object()?,
|
||||||
|
observed_at_unix_secs,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if windows.is_empty() && observed_at_unix_secs.is_none() && !is_forbidden {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage_ratio = quota_windows_usage_ratio(&windows);
|
||||||
|
let reset_seconds = quota_windows_min_reset_seconds(&windows);
|
||||||
|
let exhausted = !is_forbidden && quota_windows_all_exhausted(&windows);
|
||||||
|
let reason = if is_forbidden {
|
||||||
|
forbidden_reason
|
||||||
|
} else if exhausted {
|
||||||
|
Some("所有模型额度已耗尽".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let label = if is_forbidden {
|
||||||
|
Some("访问受限")
|
||||||
|
} else if exhausted {
|
||||||
|
Some("额度耗尽")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let code = if is_forbidden {
|
||||||
|
"forbidden"
|
||||||
|
} else if exhausted {
|
||||||
|
"exhausted"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(json!({
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "antigravity",
|
||||||
|
"code": code,
|
||||||
|
"label": label,
|
||||||
|
"reason": reason,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": source,
|
||||||
|
"observed_at": observed_at_unix_secs,
|
||||||
|
"exhausted": exhausted,
|
||||||
|
"usage_ratio": usage_ratio,
|
||||||
|
"updated_at": observed_at_unix_secs,
|
||||||
|
"reset_seconds": reset_seconds,
|
||||||
|
"plan_type": serde_json::Value::Null,
|
||||||
|
"windows": windows,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_gemini_cli_quota_status_snapshot(
|
||||||
|
upstream_metadata: Option<&Value>,
|
||||||
|
source: &str,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "gemini_cli")?;
|
||||||
|
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
|
||||||
|
let windows = provider_quota_model_bucket(metadata)
|
||||||
|
.map(|models| {
|
||||||
|
models
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(model_name, item)| {
|
||||||
|
model_quota_window_snapshot(
|
||||||
|
model_name,
|
||||||
|
item.as_object()?,
|
||||||
|
observed_at_unix_secs,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if windows.is_empty() && observed_at_unix_secs.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage_ratio = quota_windows_usage_ratio(&windows);
|
||||||
|
let active_exhausted_windows = windows
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_object)
|
||||||
|
.filter(|window| {
|
||||||
|
window
|
||||||
|
.get("is_exhausted")
|
||||||
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||||
|
.or_else(|| {
|
||||||
|
window
|
||||||
|
.get("used_ratio")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.map(|value| value >= 1.0 - 1e-6)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.filter(|window| {
|
||||||
|
provider_quota_timestamp_unix_secs(window.get("reset_at"))
|
||||||
|
.zip(observed_at_unix_secs)
|
||||||
|
.map(|(reset_at, observed_at)| reset_at > observed_at)
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let exhausted = !windows.is_empty() && active_exhausted_windows == windows.len();
|
||||||
|
let cooling = active_exhausted_windows > 0;
|
||||||
|
let reset_seconds = if cooling {
|
||||||
|
quota_windows_min_reset_seconds(&windows)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(json!({
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "gemini_cli",
|
||||||
|
"code": if exhausted {
|
||||||
|
"exhausted"
|
||||||
|
} else if cooling {
|
||||||
|
"cooldown"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
},
|
||||||
|
"label": if cooling { Some("冷却中") } else { None::<&str> },
|
||||||
|
"reason": if exhausted {
|
||||||
|
Some("所有模型均处于冷却中")
|
||||||
|
} else {
|
||||||
|
None::<&str>
|
||||||
|
},
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": source,
|
||||||
|
"observed_at": observed_at_unix_secs,
|
||||||
|
"exhausted": exhausted,
|
||||||
|
"usage_ratio": usage_ratio,
|
||||||
|
"updated_at": observed_at_unix_secs,
|
||||||
|
"reset_seconds": reset_seconds,
|
||||||
|
"plan_type": serde_json::Value::Null,
|
||||||
|
"windows": windows,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sync_provider_key_quota_status_snapshot(
|
||||||
|
status_snapshot: Option<&Value>,
|
||||||
|
provider_type: &str,
|
||||||
|
upstream_metadata: Option<&Value>,
|
||||||
|
source: &str,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
|
let quota = match normalized_provider_type.as_str() {
|
||||||
|
"codex" => build_codex_quota_status_snapshot(upstream_metadata, source),
|
||||||
|
"kiro" => build_kiro_quota_status_snapshot(upstream_metadata, source),
|
||||||
|
"antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source),
|
||||||
|
"gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let default_snapshot = default_provider_key_status_snapshot();
|
||||||
|
let mut snapshot = provider_key_status_snapshot_object(status_snapshot)
|
||||||
|
.or_else(|| default_snapshot.as_object().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
snapshot.insert("quota".to_string(), quota);
|
||||||
|
Some(Value::Object(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota_snapshot_has_materialized_data(
|
||||||
|
quota_snapshot: Option<&Map<String, Value>>,
|
||||||
|
provider_type: &str,
|
||||||
|
) -> bool {
|
||||||
|
let Some(quota_snapshot) = quota_snapshot else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
|
let snapshot_provider_type = quota_snapshot
|
||||||
|
.get("provider_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if !snapshot_provider_type.is_empty() && snapshot_provider_type != normalized_provider_type {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if quota_snapshot
|
||||||
|
.get("windows")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|windows| !windows.is_empty())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if quota_snapshot
|
||||||
|
.get("credits")
|
||||||
|
.is_some_and(|credits| !credits.is_null())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
quota_snapshot
|
||||||
|
.get("code")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|code| {
|
||||||
|
!code.is_empty()
|
||||||
|
&& !code.eq_ignore_ascii_case("unknown")
|
||||||
|
&& !code.eq_ignore_ascii_case("ok")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn provider_key_status_snapshot_payload(
|
pub(crate) fn provider_key_status_snapshot_payload(
|
||||||
key: &StoredProviderCatalogKey,
|
key: &StoredProviderCatalogKey,
|
||||||
|
provider_type: &str,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
key.status_snapshot
|
let status_snapshot = key
|
||||||
.clone()
|
.status_snapshot
|
||||||
.filter(|value| value.is_object())
|
.as_ref()
|
||||||
.unwrap_or_else(default_provider_key_status_snapshot)
|
.filter(|value| value.is_object());
|
||||||
|
let quota_snapshot = status_snapshot
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|snapshot| snapshot.get("quota"))
|
||||||
|
.and_then(Value::as_object);
|
||||||
|
|
||||||
|
if quota_snapshot_has_materialized_data(quota_snapshot, provider_type) {
|
||||||
|
return status_snapshot
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(default_provider_key_status_snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_provider_key_quota_status_snapshot(
|
||||||
|
status_snapshot,
|
||||||
|
provider_type,
|
||||||
|
key.upstream_metadata.as_ref(),
|
||||||
|
"catalog_fallback",
|
||||||
|
)
|
||||||
|
.or_else(|| status_snapshot.cloned())
|
||||||
|
.unwrap_or_else(default_provider_key_status_snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn provider_key_health_summary(
|
pub(crate) fn provider_key_health_summary(
|
||||||
@@ -514,7 +1185,7 @@ pub(crate) fn build_admin_provider_key_response(
|
|||||||
);
|
);
|
||||||
payload.insert(
|
payload.insert(
|
||||||
"status_snapshot".to_string(),
|
"status_snapshot".to_string(),
|
||||||
provider_key_status_snapshot_payload(key),
|
provider_key_status_snapshot_payload(key, provider_type),
|
||||||
);
|
);
|
||||||
payload.insert(
|
payload.insert(
|
||||||
"cache_ttl_minutes".to_string(),
|
"cache_ttl_minutes".to_string(),
|
||||||
@@ -664,3 +1335,209 @@ pub(crate) fn build_admin_provider_key_response(
|
|||||||
);
|
);
|
||||||
serde_json::Value::Object(payload)
|
serde_json::Value::Object(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_catalog_key() -> StoredProviderCatalogKey {
|
||||||
|
let encrypted_api_key =
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-test-123")
|
||||||
|
.expect("api key ciphertext should build");
|
||||||
|
StoredProviderCatalogKey::new(
|
||||||
|
"key-test".to_string(),
|
||||||
|
"provider-test".to_string(),
|
||||||
|
"default".to_string(),
|
||||||
|
"api_key".to_string(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("key should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
Some(json!(["openai:chat"])),
|
||||||
|
encrypted_api_key,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("key transport should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_key_status_snapshot_payload_backfills_missing_quota_from_upstream_metadata() {
|
||||||
|
let mut key = sample_catalog_key();
|
||||||
|
key.upstream_metadata = Some(json!({
|
||||||
|
"codex": {
|
||||||
|
"updated_at": 1_775_553_285u64,
|
||||||
|
"plan_type": "plus",
|
||||||
|
"primary_used_percent": 55.0,
|
||||||
|
"primary_reset_at": 1_900_000_000u64,
|
||||||
|
"secondary_used_percent": 12.5,
|
||||||
|
"secondary_reset_at": 1_900_500_000u64,
|
||||||
|
"has_credits": true,
|
||||||
|
"credits_balance": 42.0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
||||||
|
let quota = payload
|
||||||
|
.get("quota")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.expect("quota snapshot should be object");
|
||||||
|
|
||||||
|
assert_eq!(quota.get("provider_type"), Some(&json!("codex")));
|
||||||
|
assert_eq!(quota.get("plan_type"), Some(&json!("plus")));
|
||||||
|
assert_eq!(quota.get("updated_at"), Some(&json!(1_775_553_285u64)));
|
||||||
|
assert_eq!(
|
||||||
|
quota
|
||||||
|
.get("credits")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|credits| credits.get("balance")),
|
||||||
|
Some(&json!(42.0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
quota.get("windows").and_then(Value::as_array).map(Vec::len),
|
||||||
|
Some(2usize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_key_status_snapshot_payload_preserves_existing_materialized_quota_snapshot() {
|
||||||
|
let mut key = sample_catalog_key();
|
||||||
|
key.upstream_metadata = Some(json!({
|
||||||
|
"codex": {
|
||||||
|
"updated_at": 100u64,
|
||||||
|
"primary_used_percent": 100.0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"oauth": {
|
||||||
|
"code": "none",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"expires_at": serde_json::Value::Null,
|
||||||
|
"invalid_at": serde_json::Value::Null,
|
||||||
|
"source": serde_json::Value::Null,
|
||||||
|
"requires_reauth": false,
|
||||||
|
"expiring_soon": false
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"code": "ok",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"blocked": false,
|
||||||
|
"source": serde_json::Value::Null,
|
||||||
|
"recoverable": false
|
||||||
|
},
|
||||||
|
"quota": {
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "codex",
|
||||||
|
"code": "ok",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": "refresh_api",
|
||||||
|
"observed_at": 200u64,
|
||||||
|
"exhausted": false,
|
||||||
|
"usage_ratio": 0.25,
|
||||||
|
"updated_at": 200u64,
|
||||||
|
"reset_seconds": 3600u64,
|
||||||
|
"plan_type": "team",
|
||||||
|
"windows": [{
|
||||||
|
"code": "weekly",
|
||||||
|
"label": "周",
|
||||||
|
"scope": "account",
|
||||||
|
"unit": "percent",
|
||||||
|
"used_ratio": 0.25,
|
||||||
|
"remaining_ratio": 0.75,
|
||||||
|
"reset_at": 1_900_000_000u64,
|
||||||
|
"reset_seconds": 3600u64
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
||||||
|
let quota = payload
|
||||||
|
.get("quota")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.expect("quota snapshot should be object");
|
||||||
|
|
||||||
|
assert_eq!(quota.get("updated_at"), Some(&json!(200u64)));
|
||||||
|
assert_eq!(quota.get("plan_type"), Some(&json!("team")));
|
||||||
|
assert_eq!(
|
||||||
|
quota
|
||||||
|
.get("windows")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|windows| windows.first())
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|window| window.get("used_ratio")),
|
||||||
|
Some(&json!(0.25))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_key_status_snapshot_payload_backfills_thin_ok_snapshot_from_upstream_metadata() {
|
||||||
|
let mut key = sample_catalog_key();
|
||||||
|
key.upstream_metadata = Some(json!({
|
||||||
|
"antigravity": {
|
||||||
|
"updated_at": 1_775_553_285u64,
|
||||||
|
"quota_by_model": {
|
||||||
|
"gemini-2.5-pro": { "used_percent": 0.0 },
|
||||||
|
"gemini-2.5-flash": { "used_percent": 25.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"oauth": {
|
||||||
|
"code": "none",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"expires_at": serde_json::Value::Null,
|
||||||
|
"invalid_at": serde_json::Value::Null,
|
||||||
|
"source": serde_json::Value::Null,
|
||||||
|
"requires_reauth": false,
|
||||||
|
"expiring_soon": false
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"code": "ok",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"blocked": false,
|
||||||
|
"source": serde_json::Value::Null,
|
||||||
|
"recoverable": false
|
||||||
|
},
|
||||||
|
"quota": {
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "antigravity",
|
||||||
|
"code": "ok",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": "refresh_api",
|
||||||
|
"observed_at": 100u64,
|
||||||
|
"exhausted": false,
|
||||||
|
"usage_ratio": 0.0,
|
||||||
|
"updated_at": 100u64,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"plan_type": serde_json::Value::Null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let payload = provider_key_status_snapshot_payload(&key, "antigravity");
|
||||||
|
let quota = payload
|
||||||
|
.get("quota")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.expect("quota snapshot should be object");
|
||||||
|
|
||||||
|
assert_eq!(quota.get("provider_type"), Some(&json!("antigravity")));
|
||||||
|
assert_eq!(quota.get("updated_at"), Some(&json!(1_775_553_285u64)));
|
||||||
|
assert_eq!(
|
||||||
|
quota.get("windows").and_then(Value::as_array).map(Vec::len),
|
||||||
|
Some(2usize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub(crate) use self::catalog::{
|
|||||||
default_provider_key_status_snapshot, effective_catalog_encryption_key,
|
default_provider_key_status_snapshot, effective_catalog_encryption_key,
|
||||||
encrypt_catalog_secret_with_fallbacks, masked_catalog_api_key, parse_catalog_auth_config_json,
|
encrypt_catalog_secret_with_fallbacks, masked_catalog_api_key, parse_catalog_auth_config_json,
|
||||||
provider_catalog_key_supports_format, provider_key_health_summary,
|
provider_catalog_key_supports_format, provider_key_health_summary,
|
||||||
provider_key_status_snapshot_payload,
|
provider_key_status_snapshot_payload, sync_provider_key_quota_status_snapshot,
|
||||||
};
|
};
|
||||||
pub(crate) use self::email_templates::{
|
pub(crate) use self::email_templates::{
|
||||||
admin_email_template_definition, admin_email_template_html_key,
|
admin_email_template_definition, admin_email_template_html_key,
|
||||||
|
|||||||
@@ -165,6 +165,21 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
assert_eq!(payload["failed"], 0);
|
assert_eq!(payload["failed"], 0);
|
||||||
assert_eq!(payload["total"], 1);
|
assert_eq!(payload["total"], 1);
|
||||||
assert_eq!(payload["results"][0]["status"], "success");
|
assert_eq!(payload["results"][0]["status"], "success");
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["provider_type"],
|
||||||
|
"codex"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["results"][0]["quota_snapshot"]["plan_type"], "plus");
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["credits"]["balance"],
|
||||||
|
json!(42.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["windows"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(2usize)
|
||||||
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
let seen_execution_runtime_request = seen_execution_runtime
|
let seen_execution_runtime_request = seen_execution_runtime
|
||||||
@@ -517,6 +532,18 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_kiro_with_trusted_ad
|
|||||||
assert_eq!(payload["failed"], 0);
|
assert_eq!(payload["failed"], 0);
|
||||||
assert_eq!(payload["total"], 1);
|
assert_eq!(payload["total"], 1);
|
||||||
assert_eq!(payload["results"][0]["status"], "success");
|
assert_eq!(payload["results"][0]["status"], "success");
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["provider_type"],
|
||||||
|
"kiro"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["plan_type"],
|
||||||
|
"KIRO PRO+"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["windows"][0]["remaining_value"],
|
||||||
|
json!(15.0)
|
||||||
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
let seen_execution_runtime_request = seen_execution_runtime
|
let seen_execution_runtime_request = seen_execution_runtime
|
||||||
@@ -566,6 +593,40 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_kiro_with_trusted_ad
|
|||||||
.and_then(|value| value.get("email")),
|
.and_then(|value| value.get("email")),
|
||||||
Some(&json!("dev@example.com"))
|
Some(&json!("dev@example.com"))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("quota"))
|
||||||
|
.and_then(|value| value.get("provider_type")),
|
||||||
|
Some(&json!("kiro"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("quota"))
|
||||||
|
.and_then(|value| value.get("usage_ratio")),
|
||||||
|
Some(&json!(0.25))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("quota"))
|
||||||
|
.and_then(|value| value.get("plan_type")),
|
||||||
|
Some(&json!("KIRO PRO+"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("quota"))
|
||||||
|
.and_then(|value| value.get("windows"))
|
||||||
|
.and_then(|value| value.get(0))
|
||||||
|
.and_then(|value| value.get("remaining_value")),
|
||||||
|
Some(&json!(15.0))
|
||||||
|
);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
execution_runtime_handle.abort();
|
execution_runtime_handle.abort();
|
||||||
@@ -830,6 +891,20 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru
|
|||||||
assert_eq!(payload["failed"], 0);
|
assert_eq!(payload["failed"], 0);
|
||||||
assert_eq!(payload["total"], 1);
|
assert_eq!(payload["total"], 1);
|
||||||
assert_eq!(payload["results"][0]["status"], "success");
|
assert_eq!(payload["results"][0]["status"], "success");
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["provider_type"],
|
||||||
|
"antigravity"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["usage_ratio"],
|
||||||
|
json!(0.75)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["quota_snapshot"]["windows"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(1usize)
|
||||||
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
let seen_execution_runtime_request = seen_execution_runtime
|
let seen_execution_runtime_request = seen_execution_runtime
|
||||||
@@ -880,6 +955,32 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru
|
|||||||
.and_then(|value| value.get("used_percent")),
|
.and_then(|value| value.get("used_percent")),
|
||||||
Some(&json!(75.0))
|
Some(&json!(75.0))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("quota"))
|
||||||
|
.and_then(|value| value.get("provider_type")),
|
||||||
|
Some(&json!("antigravity"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("quota"))
|
||||||
|
.and_then(|value| value.get("usage_ratio")),
|
||||||
|
Some(&json!(0.75))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("quota"))
|
||||||
|
.and_then(|value| value.get("windows"))
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(1usize)
|
||||||
|
);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
execution_runtime_handle.abort();
|
execution_runtime_handle.abort();
|
||||||
|
|||||||
@@ -2564,6 +2564,13 @@ async fn gateway_batch_imports_admin_provider_oauth_kiro_via_execution_runtime_p
|
|||||||
assert_eq!(payload["results"][0]["key_id"], "key-kiro-batch-runtime");
|
assert_eq!(payload["results"][0]["key_id"], "key-kiro-batch-runtime");
|
||||||
assert_eq!(payload["results"][0]["replaced"], true);
|
assert_eq!(payload["results"][0]["replaced"], true);
|
||||||
|
|
||||||
|
for _ in 0..40 {
|
||||||
|
let plan_count = execution_plans.lock().expect("mutex should lock").len();
|
||||||
|
if plan_count == 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let plans = execution_plans.lock().expect("mutex should lock");
|
let plans = execution_plans.lock().expect("mutex should lock");
|
||||||
assert_eq!(plans.len(), 2);
|
assert_eq!(plans.len(), 2);
|
||||||
|
|||||||
@@ -1037,6 +1037,224 @@ async fn gateway_includes_pool_quota_and_compat_fields_in_list_keys_response() {
|
|||||||
assert_eq!(keys[0]["account_status_blocked"], json!(false));
|
assert_eq!(keys[0]["account_status_blocked"], json!(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_prefers_status_snapshot_antigravity_quota_over_stale_metadata() {
|
||||||
|
let mut provider = sample_provider("provider-antigravity", "antigravity", 10)
|
||||||
|
.with_transport_fields(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"pool_advanced": {
|
||||||
|
"enabled": true,
|
||||||
|
"skip_exhausted_accounts": true
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
provider.provider_type = "antigravity".to_string();
|
||||||
|
|
||||||
|
let mut key = sample_key(
|
||||||
|
"key-antigravity-snapshot-fresh",
|
||||||
|
"provider-antigravity",
|
||||||
|
"gemini:chat",
|
||||||
|
"oauth-placeholder",
|
||||||
|
);
|
||||||
|
key.name = "antigravity snapshot fresh".to_string();
|
||||||
|
key.auth_type = "oauth".to_string();
|
||||||
|
key.upstream_metadata = Some(json!({
|
||||||
|
"antigravity": {
|
||||||
|
"quota_by_model": {
|
||||||
|
"gemini-2.5-pro": { "used_percent": 100.0 },
|
||||||
|
"gemini-2.5-flash": { "used_percent": 100.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"quota": {
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "antigravity",
|
||||||
|
"code": "ok",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": "refresh_api",
|
||||||
|
"observed_at": 1_775_553_285u64,
|
||||||
|
"exhausted": false,
|
||||||
|
"usage_ratio": 0.0,
|
||||||
|
"updated_at": 1_775_553_285u64,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"plan_type": serde_json::Value::Null,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"code": "model:gemini-2.5-pro",
|
||||||
|
"label": "Gemini 2.5 Pro",
|
||||||
|
"scope": "model",
|
||||||
|
"unit": "percent",
|
||||||
|
"model": "gemini-2.5-pro",
|
||||||
|
"used_ratio": 0.0,
|
||||||
|
"remaining_ratio": 1.0,
|
||||||
|
"reset_at": serde_json::Value::Null,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"is_exhausted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "model:gemini-2.5-flash",
|
||||||
|
"label": "Gemini 2.5 Flash",
|
||||||
|
"scope": "model",
|
||||||
|
"unit": "percent",
|
||||||
|
"model": "gemini-2.5-flash",
|
||||||
|
"used_ratio": 0.0,
|
||||||
|
"remaining_ratio": 1.0,
|
||||||
|
"reset_at": serde_json::Value::Null,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"is_exhausted": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
Vec::new(),
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = local_admin_pool_response(
|
||||||
|
&state,
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/pool/provider-antigravity/keys?page=1&page_size=50&status=all",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("json body should parse");
|
||||||
|
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||||
|
|
||||||
|
assert_eq!(keys[0]["scheduling_status"], json!("available"));
|
||||||
|
assert_eq!(keys[0]["scheduling_reason"], json!("available"));
|
||||||
|
assert_eq!(keys[0]["quota_updated_at"], json!(1_775_553_285u64));
|
||||||
|
assert_eq!(keys[0]["account_quota"], json!("最低剩余 100.0% (2 模型)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_renders_gemini_cli_account_quota_from_status_snapshot() {
|
||||||
|
let mut provider = sample_provider("provider-gemini-cli", "gemini_cli", 10)
|
||||||
|
.with_transport_fields(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"pool_advanced": {
|
||||||
|
"enabled": true,
|
||||||
|
"skip_exhausted_accounts": true
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
provider.provider_type = "gemini_cli".to_string();
|
||||||
|
|
||||||
|
let mut key = sample_key(
|
||||||
|
"key-gemini-cli-snapshot",
|
||||||
|
"provider-gemini-cli",
|
||||||
|
"gemini:chat",
|
||||||
|
"oauth-placeholder",
|
||||||
|
);
|
||||||
|
key.name = "gemini cli snapshot".to_string();
|
||||||
|
key.auth_type = "oauth".to_string();
|
||||||
|
key.upstream_metadata = Some(json!({
|
||||||
|
"gemini_cli": {
|
||||||
|
"quota_by_model": {
|
||||||
|
"gemini-2.5-pro": {
|
||||||
|
"is_exhausted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"quota": {
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "gemini_cli",
|
||||||
|
"code": "cooldown",
|
||||||
|
"label": "冷却中",
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": "background_refresh",
|
||||||
|
"observed_at": 1_775_553_285u64,
|
||||||
|
"exhausted": false,
|
||||||
|
"usage_ratio": 1.0,
|
||||||
|
"updated_at": 1_775_553_285u64,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"plan_type": serde_json::Value::Null,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"code": "model:gemini-2.5-pro",
|
||||||
|
"label": "Gemini 2.5 Pro",
|
||||||
|
"scope": "model",
|
||||||
|
"unit": "percent",
|
||||||
|
"model": "gemini-2.5-pro",
|
||||||
|
"used_ratio": 1.0,
|
||||||
|
"remaining_ratio": 0.0,
|
||||||
|
"reset_at": serde_json::Value::Null,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"is_exhausted": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
Vec::new(),
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = local_admin_pool_response(
|
||||||
|
&state,
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/pool/provider-gemini-cli/keys?page=1&page_size=50&status=all",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("json body should parse");
|
||||||
|
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||||
|
|
||||||
|
assert_eq!(keys[0]["scheduling_status"], json!("available"));
|
||||||
|
assert_eq!(keys[0]["quota_updated_at"], json!(1_775_553_285u64));
|
||||||
|
assert_eq!(keys[0]["account_quota"], json!("Gemini 2.5 Pro 冷却中"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_formats_codex_quota_countdown_from_reset_after_seconds() {
|
async fn gateway_formats_codex_quota_countdown_from_reset_after_seconds() {
|
||||||
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||||
@@ -1192,6 +1410,124 @@ async fn gateway_marks_exhausted_codex_pool_key_as_blocked_when_flag_enabled() {
|
|||||||
assert_eq!(keys[0]["account_quota"], json!("5H剩余 0.0%"));
|
assert_eq!(keys[0]["account_quota"], json!("5H剩余 0.0%"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_prefers_status_snapshot_codex_quota_over_stale_metadata() {
|
||||||
|
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"pool_advanced": {
|
||||||
|
"enabled": true,
|
||||||
|
"skip_exhausted_accounts": true
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
provider.provider_type = "codex".to_string();
|
||||||
|
|
||||||
|
let mut key = sample_key(
|
||||||
|
"key-codex-snapshot-fresh",
|
||||||
|
"provider-codex",
|
||||||
|
"openai:cli",
|
||||||
|
"oauth-placeholder",
|
||||||
|
);
|
||||||
|
key.name = "codex snapshot fresh".to_string();
|
||||||
|
key.auth_type = "oauth".to_string();
|
||||||
|
key.upstream_metadata = Some(json!({
|
||||||
|
"codex": {
|
||||||
|
"plan_type": "plus",
|
||||||
|
"secondary_used_percent": 100.0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"quota": {
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "codex",
|
||||||
|
"code": "ok",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": "response_headers",
|
||||||
|
"observed_at": 1_775_553_285u64,
|
||||||
|
"exhausted": false,
|
||||||
|
"usage_ratio": 0.0,
|
||||||
|
"updated_at": 1_775_553_285u64,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"plan_type": "plus",
|
||||||
|
"credits": {
|
||||||
|
"has_credits": true,
|
||||||
|
"balance": 12.5,
|
||||||
|
"unlimited": false
|
||||||
|
},
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"code": "weekly",
|
||||||
|
"label": "周",
|
||||||
|
"scope": "account",
|
||||||
|
"unit": "percent",
|
||||||
|
"used_ratio": 0.0,
|
||||||
|
"remaining_ratio": 1.0,
|
||||||
|
"reset_at": serde_json::Value::Null,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"window_minutes": 10_080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5h",
|
||||||
|
"label": "5H",
|
||||||
|
"scope": "account",
|
||||||
|
"unit": "percent",
|
||||||
|
"used_ratio": 0.0,
|
||||||
|
"remaining_ratio": 1.0,
|
||||||
|
"reset_at": serde_json::Value::Null,
|
||||||
|
"reset_seconds": serde_json::Value::Null,
|
||||||
|
"window_minutes": 300
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
Vec::new(),
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = local_admin_pool_response(
|
||||||
|
&state,
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/pool/provider-codex/keys?page=1&page_size=50&status=all",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("json body should parse");
|
||||||
|
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||||
|
|
||||||
|
assert_eq!(keys[0]["scheduling_status"], json!("available"));
|
||||||
|
assert_eq!(keys[0]["scheduling_reason"], json!("available"));
|
||||||
|
assert_eq!(keys[0]["quota_updated_at"], json!(1_775_553_285u64));
|
||||||
|
assert_eq!(
|
||||||
|
keys[0]["account_quota"],
|
||||||
|
json!("周剩余 100.0% | 5H剩余 100.0%")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_marks_exhausted_kiro_pool_key_as_blocked_when_flag_enabled() {
|
async fn gateway_marks_exhausted_kiro_pool_key_as_blocked_when_flag_enabled() {
|
||||||
let mut provider = sample_provider("provider-kiro", "kiro", 10).with_transport_fields(
|
let mut provider = sample_provider("provider-kiro", "kiro", 10).with_transport_fields(
|
||||||
@@ -1265,6 +1601,108 @@ async fn gateway_marks_exhausted_kiro_pool_key_as_blocked_when_flag_enabled() {
|
|||||||
assert_eq!(keys[0]["account_quota"], json!("剩余 0/100"));
|
assert_eq!(keys[0]["account_quota"], json!("剩余 0/100"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_prefers_status_snapshot_kiro_quota_over_stale_metadata() {
|
||||||
|
let mut provider = sample_provider("provider-kiro", "kiro", 10).with_transport_fields(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"pool_advanced": {
|
||||||
|
"enabled": true,
|
||||||
|
"skip_exhausted_accounts": true
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
provider.provider_type = "kiro".to_string();
|
||||||
|
|
||||||
|
let mut key = sample_key(
|
||||||
|
"key-kiro-snapshot-fresh",
|
||||||
|
"provider-kiro",
|
||||||
|
"claude:cli",
|
||||||
|
"oauth-placeholder",
|
||||||
|
);
|
||||||
|
key.name = "kiro snapshot fresh".to_string();
|
||||||
|
key.auth_type = "oauth".to_string();
|
||||||
|
key.upstream_metadata = Some(json!({
|
||||||
|
"kiro": {
|
||||||
|
"remaining": 0.0,
|
||||||
|
"usage_limit": 100.0,
|
||||||
|
"current_usage": 100.0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"quota": {
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "kiro",
|
||||||
|
"code": "ok",
|
||||||
|
"label": serde_json::Value::Null,
|
||||||
|
"reason": serde_json::Value::Null,
|
||||||
|
"freshness": "fresh",
|
||||||
|
"source": "refresh_api",
|
||||||
|
"observed_at": 1_775_553_285u64,
|
||||||
|
"exhausted": false,
|
||||||
|
"usage_ratio": 0.25,
|
||||||
|
"updated_at": 1_775_553_285u64,
|
||||||
|
"reset_seconds": 86_400u64,
|
||||||
|
"plan_type": "KIRO PRO+",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"code": "usage",
|
||||||
|
"label": "额度",
|
||||||
|
"scope": "account",
|
||||||
|
"unit": "count",
|
||||||
|
"used_ratio": 0.25,
|
||||||
|
"remaining_ratio": 0.75,
|
||||||
|
"used_value": 5.0,
|
||||||
|
"remaining_value": 15.0,
|
||||||
|
"limit_value": 20.0,
|
||||||
|
"reset_at": 1_775_639_685u64,
|
||||||
|
"reset_seconds": 86_400u64
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
Vec::new(),
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = local_admin_pool_response(
|
||||||
|
&state,
|
||||||
|
http::Method::GET,
|
||||||
|
"/api/admin/pool/provider-kiro/keys?page=1&page_size=50&status=all",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("json body should parse");
|
||||||
|
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||||
|
|
||||||
|
assert_eq!(keys[0]["scheduling_status"], json!("available"));
|
||||||
|
assert_eq!(keys[0]["scheduling_reason"], json!("available"));
|
||||||
|
assert_eq!(keys[0]["quota_updated_at"], json!(1_775_553_285u64));
|
||||||
|
assert_eq!(keys[0]["account_quota"], json!("剩余 75.0% (5/20)"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_codex_quota_resets_to_full_after_countdown_elapsed() {
|
async fn gateway_codex_quota_resets_to_full_after_countdown_elapsed() {
|
||||||
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||||
|
|||||||
@@ -288,15 +288,15 @@ async fn gateway_executes_openai_video_delete_via_reconstructed_data_backed_loca
|
|||||||
|
|
||||||
let gateway = build_router_with_state(
|
let gateway = build_router_with_state(
|
||||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||||
.with_video_task_truth_source_mode(VideoTaskTruthSourceMode::RustAuthoritative)
|
.with_video_task_truth_source_mode(VideoTaskTruthSourceMode::RustAuthoritative)
|
||||||
.with_data_state_for_tests(
|
.with_data_state_for_tests(
|
||||||
crate::data::GatewayDataState::with_video_task_provider_transport_and_request_candidate_repository_for_tests(
|
crate::data::GatewayDataState::with_video_task_provider_transport_and_request_candidate_repository_for_tests(
|
||||||
repository,
|
repository,
|
||||||
provider_catalog_repository,
|
provider_catalog_repository,
|
||||||
Arc::clone(&request_candidate_repository),
|
Arc::clone(&request_candidate_repository),
|
||||||
DEVELOPMENT_ENCRYPTION_KEY,
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
@@ -308,8 +308,15 @@ async fn gateway_executes_openai_video_delete_via_reconstructed_data_backed_loca
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
let response_status = response.status();
|
||||||
let response_json: serde_json::Value = response.json().await.expect("body should parse");
|
let response_text = response.text().await.expect("body should read");
|
||||||
|
assert_eq!(
|
||||||
|
response_status,
|
||||||
|
StatusCode::OK,
|
||||||
|
"unexpected response body: {response_text}"
|
||||||
|
);
|
||||||
|
let response_json: serde_json::Value =
|
||||||
|
serde_json::from_str(&response_text).expect("body should parse");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response_json,
|
response_json,
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::sync::{Mutex, OnceLock};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::clock::current_unix_secs;
|
use crate::clock::current_unix_secs;
|
||||||
|
use crate::handlers::shared::sync_provider_key_quota_status_snapshot;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
use aether_admin::provider::quota as admin_provider_quota_pure;
|
use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -185,14 +186,21 @@ pub(super) async fn sync_codex_quota_from_response_headers(
|
|||||||
|
|
||||||
let updated_upstream_metadata =
|
let updated_upstream_metadata =
|
||||||
merge_metadata_object(key.upstream_metadata.as_ref(), "codex", parsed);
|
merge_metadata_object(key.upstream_metadata.as_ref(), "codex", parsed);
|
||||||
|
let updated_status_snapshot = sync_provider_key_quota_status_snapshot(
|
||||||
|
key.status_snapshot.as_ref(),
|
||||||
|
provider.provider_type.as_str(),
|
||||||
|
updated_upstream_metadata.as_ref(),
|
||||||
|
"response_headers",
|
||||||
|
);
|
||||||
|
let mut updated_key = key;
|
||||||
|
updated_key.upstream_metadata = updated_upstream_metadata;
|
||||||
|
updated_key.status_snapshot = updated_status_snapshot;
|
||||||
|
updated_key.updated_at_unix_secs = Some(now_unix_secs);
|
||||||
|
|
||||||
let updated = state
|
let updated = state
|
||||||
.update_provider_catalog_key_upstream_metadata(
|
.update_provider_catalog_key(&updated_key)
|
||||||
&key_id,
|
.await?
|
||||||
updated_upstream_metadata.as_ref(),
|
.is_some();
|
||||||
Some(now_unix_secs),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if updated {
|
if updated {
|
||||||
set_cached_fingerprint(&key_id, incoming_fingerprint, now);
|
set_cached_fingerprint(&key_id, incoming_fingerprint, now);
|
||||||
}
|
}
|
||||||
@@ -270,6 +278,15 @@ mod tests {
|
|||||||
key
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn quota_snapshot<'a>(key: &'a StoredProviderCatalogKey) -> &'a serde_json::Map<String, Value> {
|
||||||
|
key.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|snapshot| snapshot.get("quota"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.expect("quota snapshot should exist")
|
||||||
|
}
|
||||||
|
|
||||||
fn paid_headers(
|
fn paid_headers(
|
||||||
primary_used_percent: &str,
|
primary_used_percent: &str,
|
||||||
secondary_used_percent: &str,
|
secondary_used_percent: &str,
|
||||||
@@ -323,25 +340,43 @@ mod tests {
|
|||||||
async fn sync_codex_quota_replaces_existing_codex_fields_and_preserves_other_sections() {
|
async fn sync_codex_quota_replaces_existing_codex_fields_and_preserves_other_sections() {
|
||||||
clear_codex_quota_fingerprint_cache();
|
clear_codex_quota_fingerprint_cache();
|
||||||
|
|
||||||
|
let mut key = sample_key(
|
||||||
|
"key-codex-1",
|
||||||
|
"provider-codex",
|
||||||
|
Some(json!({
|
||||||
|
"codex": {
|
||||||
|
"legacy_marker": "drop-me",
|
||||||
|
"secondary_used_percent": 2.0,
|
||||||
|
"credits_balance": 42.0,
|
||||||
|
"account_disabled": true,
|
||||||
|
"reason": "deactivated_workspace"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"oauth": {
|
||||||
|
"code": "valid",
|
||||||
|
"label": "有效",
|
||||||
|
"requires_reauth": false,
|
||||||
|
"expiring_soon": false
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"code": "ok",
|
||||||
|
"blocked": false,
|
||||||
|
"recoverable": false
|
||||||
|
},
|
||||||
|
"quota": {
|
||||||
|
"code": "unknown",
|
||||||
|
"exhausted": false
|
||||||
|
}
|
||||||
|
}));
|
||||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
vec![sample_provider("provider-codex", "codex")],
|
vec![sample_provider("provider-codex", "codex")],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![sample_key(
|
vec![key],
|
||||||
"key-codex-1",
|
|
||||||
"provider-codex",
|
|
||||||
Some(json!({
|
|
||||||
"codex": {
|
|
||||||
"legacy_marker": "drop-me",
|
|
||||||
"secondary_used_percent": 2.0,
|
|
||||||
"credits_balance": 42.0,
|
|
||||||
"account_disabled": true,
|
|
||||||
"reason": "deactivated_workspace"
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"value": true
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
)],
|
|
||||||
));
|
));
|
||||||
let state = build_state(Arc::clone(&repository));
|
let state = build_state(Arc::clone(&repository));
|
||||||
|
|
||||||
@@ -386,6 +421,38 @@ mod tests {
|
|||||||
.and_then(|metadata| metadata.get("other")),
|
.and_then(|metadata| metadata.get("other")),
|
||||||
Some(&json!({"value": true}))
|
Some(&json!({"value": true}))
|
||||||
);
|
);
|
||||||
|
let quota = quota_snapshot(&reloaded[0]);
|
||||||
|
assert_eq!(quota.get("version"), Some(&json!(2)));
|
||||||
|
assert_eq!(quota.get("provider_type"), Some(&json!("codex")));
|
||||||
|
assert_eq!(quota.get("source"), Some(&json!("response_headers")));
|
||||||
|
assert_eq!(quota.get("code"), Some(&json!("exhausted")));
|
||||||
|
assert_eq!(quota.get("exhausted"), Some(&json!(true)));
|
||||||
|
assert_eq!(quota.get("plan_type"), Some(&json!("team")));
|
||||||
|
assert_eq!(quota.get("usage_ratio"), Some(&json!(1.0)));
|
||||||
|
assert_eq!(quota.get("updated_at"), quota.get("observed_at"));
|
||||||
|
let windows = quota
|
||||||
|
.get("windows")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("windows should be array");
|
||||||
|
assert_eq!(windows.len(), 2);
|
||||||
|
assert_eq!(windows[0].get("code"), Some(&json!("weekly")));
|
||||||
|
assert_eq!(windows[1].get("code"), Some(&json!("5h")));
|
||||||
|
let oauth = reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|snapshot| snapshot.get("oauth"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.expect("oauth snapshot should exist");
|
||||||
|
assert_eq!(oauth.get("code"), Some(&json!("valid")));
|
||||||
|
let account = reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|snapshot| snapshot.get("account"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.expect("account snapshot should exist");
|
||||||
|
assert_eq!(account.get("code"), Some(&json!("ok")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -873,6 +873,17 @@ mod tests {
|
|||||||
.expect("codex metadata should exist");
|
.expect("codex metadata should exist");
|
||||||
assert_eq!(codex.get("primary_used_percent"), Some(&json!(31.0)));
|
assert_eq!(codex.get("primary_used_percent"), Some(&json!(31.0)));
|
||||||
assert_eq!(codex.get("secondary_used_percent"), Some(&json!(100.0)));
|
assert_eq!(codex.get("secondary_used_percent"), Some(&json!(100.0)));
|
||||||
|
let quota = reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(serde_json::Value::as_object)
|
||||||
|
.and_then(|snapshot| snapshot.get("quota"))
|
||||||
|
.and_then(serde_json::Value::as_object)
|
||||||
|
.expect("quota snapshot should exist");
|
||||||
|
assert_eq!(quota.get("provider_type"), Some(&json!("codex")));
|
||||||
|
assert_eq!(quota.get("source"), Some(&json!("response_headers")));
|
||||||
|
assert_eq!(quota.get("code"), Some(&json!("exhausted")));
|
||||||
|
assert_eq!(quota.get("updated_at"), quota.get("observed_at"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -925,6 +936,17 @@ mod tests {
|
|||||||
.expect("codex metadata should exist");
|
.expect("codex metadata should exist");
|
||||||
assert_eq!(codex.get("primary_used_percent"), Some(&json!(31.0)));
|
assert_eq!(codex.get("primary_used_percent"), Some(&json!(31.0)));
|
||||||
assert_eq!(codex.get("secondary_used_percent"), Some(&json!(100.0)));
|
assert_eq!(codex.get("secondary_used_percent"), Some(&json!(100.0)));
|
||||||
|
let quota = reloaded[0]
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(serde_json::Value::as_object)
|
||||||
|
.and_then(|snapshot| snapshot.get("quota"))
|
||||||
|
.and_then(serde_json::Value::as_object)
|
||||||
|
.expect("quota snapshot should exist");
|
||||||
|
assert_eq!(quota.get("provider_type"), Some(&json!("codex")));
|
||||||
|
assert_eq!(quota.get("source"), Some(&json!("response_headers")));
|
||||||
|
assert_eq!(quota.get("code"), Some(&json!("exhausted")));
|
||||||
|
assert_eq!(quota.get("updated_at"), quota.get("observed_at"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -121,10 +121,72 @@ fn admin_pool_json_f64(value: Option<&Value>) -> Option<f64> {
|
|||||||
.filter(|value| value.is_finite())
|
.filter(|value| value.is_finite())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn admin_pool_quota_snapshot_matches_provider(
|
||||||
|
quota_snapshot: &serde_json::Map<String, Value>,
|
||||||
|
provider_type: &str,
|
||||||
|
) -> bool {
|
||||||
|
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
|
match quota_snapshot
|
||||||
|
.get("provider_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
Some(provider_type) => provider_type.eq_ignore_ascii_case(&normalized_provider_type),
|
||||||
|
None => {
|
||||||
|
admin_pool_json_bool(quota_snapshot.get("exhausted")) == Some(true)
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("code")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|code| !code.trim().eq_ignore_ascii_case("unknown"))
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("updated_at")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("observed_at")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("usage_ratio")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("reset_seconds")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("windows")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|windows| !windows.is_empty())
|
||||||
|
|| quota_snapshot
|
||||||
|
.get("credits")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.is_some_and(|credits| !credits.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_pool_key_quota_snapshot<'a>(
|
||||||
|
key: &'a StoredProviderCatalogKey,
|
||||||
|
provider_type: &str,
|
||||||
|
) -> Option<&'a serde_json::Map<String, Value>> {
|
||||||
|
let quota_snapshot = key
|
||||||
|
.status_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|snapshot| snapshot.get("quota"))
|
||||||
|
.and_then(Value::as_object)?;
|
||||||
|
admin_pool_quota_snapshot_matches_provider(quota_snapshot, provider_type)
|
||||||
|
.then_some(quota_snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn admin_pool_key_account_quota_exhausted(
|
pub fn admin_pool_key_account_quota_exhausted(
|
||||||
key: &StoredProviderCatalogKey,
|
key: &StoredProviderCatalogKey,
|
||||||
provider_type: &str,
|
provider_type: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
if let Some(exhausted) = admin_pool_key_quota_snapshot(key, provider_type)
|
||||||
|
.and_then(|quota_snapshot| admin_pool_json_bool(quota_snapshot.get("exhausted")))
|
||||||
|
{
|
||||||
|
return exhausted;
|
||||||
|
}
|
||||||
|
|
||||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
let Some(bucket) = admin_pool_metadata_bucket(key.upstream_metadata.as_ref(), &provider_type)
|
let Some(bucket) = admin_pool_metadata_bucket(key.upstream_metadata.as_ref(), &provider_type)
|
||||||
else {
|
else {
|
||||||
@@ -663,6 +725,39 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefers_quota_snapshot_over_metadata_for_codex_exhaustion() {
|
||||||
|
let mut key = sample_key(Some(json!({
|
||||||
|
"codex": {
|
||||||
|
"secondary_used_percent": 100.0
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
key.status_snapshot = Some(json!({
|
||||||
|
"quota": {
|
||||||
|
"version": 2,
|
||||||
|
"provider_type": "codex",
|
||||||
|
"code": "ok",
|
||||||
|
"exhausted": false,
|
||||||
|
"usage_ratio": 0.0,
|
||||||
|
"updated_at": 1_776_395_200u64,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"code": "weekly",
|
||||||
|
"used_ratio": 0.0,
|
||||||
|
"remaining_ratio": 1.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5h",
|
||||||
|
"used_ratio": 0.0,
|
||||||
|
"remaining_ratio": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(!admin_pool_key_account_quota_exhausted(&key, "codex"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_kiro_exhaustion_from_metadata() {
|
fn detects_kiro_exhaustion_from_metadata() {
|
||||||
assert!(admin_pool_key_account_quota_exhausted(
|
assert!(admin_pool_key_account_quota_exhausted(
|
||||||
|
|||||||
@@ -145,14 +145,11 @@ pub fn parse_antigravity_usage_response(
|
|||||||
let remaining_fraction = quota_info
|
let remaining_fraction = quota_info
|
||||||
.and_then(|object| object.get("remainingFraction"))
|
.and_then(|object| object.get("remainingFraction"))
|
||||||
.and_then(coerce_json_f64);
|
.and_then(coerce_json_f64);
|
||||||
let used_percent = remaining_fraction
|
if let Some(remaining_fraction) = remaining_fraction {
|
||||||
.map(|value| ((1.0 - value).max(0.0) * 100.0).min(100.0))
|
let used_percent = ((1.0 - remaining_fraction).max(0.0) * 100.0).min(100.0);
|
||||||
.unwrap_or(100.0);
|
payload.insert("remaining_fraction".to_string(), json!(remaining_fraction));
|
||||||
payload.insert(
|
payload.insert("used_percent".to_string(), json!(used_percent));
|
||||||
"remaining_fraction".to_string(),
|
}
|
||||||
json!(remaining_fraction.unwrap_or(0.0)),
|
|
||||||
);
|
|
||||||
payload.insert("used_percent".to_string(), json!(used_percent));
|
|
||||||
if let Some(reset_time) = quota_info
|
if let Some(reset_time) = quota_info
|
||||||
.and_then(|object| object.get("resetTime"))
|
.and_then(|object| object.get("resetTime"))
|
||||||
.cloned()
|
.cloned()
|
||||||
|
|||||||
@@ -417,7 +417,16 @@ mod tests {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Some(Self::start(initdb_bin, postgres_bin).await?))
|
match Self::start(initdb_bin, postgres_bin).await {
|
||||||
|
Ok(server) => Ok(Some(server)),
|
||||||
|
Err(err) if postgres_shared_memory_unavailable(err.to_string().as_str()) => {
|
||||||
|
eprintln!(
|
||||||
|
"skipping postgres integration test because local postgres could not allocate shared memory: {err}"
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start(
|
async fn start(
|
||||||
@@ -468,6 +477,14 @@ mod tests {
|
|||||||
.arg("synchronous_commit=off")
|
.arg("synchronous_commit=off")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg("full_page_writes=off")
|
.arg("full_page_writes=off")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("shared_buffers=8MB")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("max_connections=8")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("dynamic_shared_memory_type=none")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("autovacuum=off")
|
||||||
.stdout(Stdio::from(stdout))
|
.stdout(Stdio::from(stdout))
|
||||||
.stderr(Stdio::from(stderr))
|
.stderr(Stdio::from(stderr))
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
@@ -519,6 +536,14 @@ mod tests {
|
|||||||
Ok(port)
|
Ok(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn postgres_shared_memory_unavailable(message: &str) -> bool {
|
||||||
|
let message = message.to_ascii_lowercase();
|
||||||
|
message.contains("shared memory")
|
||||||
|
&& (message.contains("could not create shared memory segment")
|
||||||
|
|| message.contains("shmget")
|
||||||
|
|| message.contains("no space left on device"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_postgres(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
async fn wait_for_postgres(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(10);
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import type { EndpointAPIKey, AllowedModels } from './types'
|
import type { EndpointAPIKey, AllowedModels } from './types'
|
||||||
|
import type { QuotaStatusSnapshot } from './types'
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type { EndpointAPIKey, AllowedModels }
|
export type { EndpointAPIKey, AllowedModels }
|
||||||
@@ -212,9 +213,18 @@ export interface RefreshQuotaResult {
|
|||||||
results: Array<{
|
results: Array<{
|
||||||
key_id: string
|
key_id: string
|
||||||
key_name: string
|
key_name: string
|
||||||
status: 'success' | 'no_metadata' | 'error'
|
status:
|
||||||
// Codex: 额度字段为扁平结构;Antigravity: 返回 { antigravity: { quota_by_model: ... } }
|
| 'success'
|
||||||
|
| 'no_metadata'
|
||||||
|
| 'quota_exhausted'
|
||||||
|
| 'workspace_deactivated'
|
||||||
|
| 'auth_invalid'
|
||||||
|
| 'forbidden'
|
||||||
|
| 'banned'
|
||||||
|
| 'error'
|
||||||
|
// provider 级 bucket 数据;前端应按当前 provider_type 包装回 upstream_metadata.<provider_type>
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
quota_snapshot?: QuotaStatusSnapshot
|
||||||
message?: string
|
message?: string
|
||||||
status_code?: number
|
status_code?: number
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export interface PoolKeyDetail {
|
|||||||
model_include_patterns?: string[] | null
|
model_include_patterns?: string[] | null
|
||||||
model_exclude_patterns?: string[] | null
|
model_exclude_patterns?: string[] | null
|
||||||
proxy?: ProxyConfig | null
|
proxy?: ProxyConfig | null
|
||||||
account_quota: string | null
|
account_quota: string | null // compatibility only; UI should prefer status_snapshot.quota
|
||||||
cooldown_reason: string | null
|
cooldown_reason: string | null
|
||||||
cooldown_ttl_seconds: number | null
|
cooldown_ttl_seconds: number | null
|
||||||
cost_window_usage: number
|
cost_window_usage: number
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './api-format'
|
|||||||
export * from './provider'
|
export * from './provider'
|
||||||
export * from './model'
|
export * from './model'
|
||||||
export * from './routing'
|
export * from './routing'
|
||||||
|
export * from './statusSnapshot'
|
||||||
|
|||||||
@@ -18,15 +18,45 @@ export interface AccountStatusSnapshot {
|
|||||||
recoverable?: boolean
|
recoverable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuotaWindowSnapshot {
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
scope?: 'account' | 'workspace' | 'model' | string
|
||||||
|
unit?: 'percent' | 'count' | 'usd' | 'tokens' | string
|
||||||
|
model?: string | null
|
||||||
|
used_ratio?: number | null
|
||||||
|
remaining_ratio?: number | null
|
||||||
|
used_value?: number | null
|
||||||
|
remaining_value?: number | null
|
||||||
|
limit_value?: number | null
|
||||||
|
reset_at?: number | null
|
||||||
|
reset_seconds?: number | null
|
||||||
|
window_minutes?: number | null
|
||||||
|
is_exhausted?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaCreditsSnapshot {
|
||||||
|
has_credits?: boolean | null
|
||||||
|
balance?: number | null
|
||||||
|
unlimited?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuotaStatusSnapshot {
|
export interface QuotaStatusSnapshot {
|
||||||
code: 'unknown' | 'ok' | 'exhausted'
|
version?: number | null
|
||||||
|
provider_type?: string | null
|
||||||
|
code: 'unknown' | 'ok' | 'exhausted' | 'cooldown' | 'forbidden' | 'banned' | string
|
||||||
label?: string | null
|
label?: string | null
|
||||||
reason?: string | null
|
reason?: string | null
|
||||||
|
freshness?: 'fresh' | 'stale' | 'unknown' | 'error' | string | null
|
||||||
|
source?: string | null
|
||||||
|
observed_at?: number | null
|
||||||
exhausted: boolean
|
exhausted: boolean
|
||||||
usage_ratio?: number | null
|
usage_ratio?: number | null
|
||||||
updated_at?: number | null
|
updated_at?: number | null
|
||||||
reset_seconds?: number | null
|
reset_seconds?: number | null
|
||||||
plan_type?: string | null
|
plan_type?: string | null
|
||||||
|
credits?: QuotaCreditsSnapshot | null
|
||||||
|
windows?: QuotaWindowSnapshot[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderKeyStatusSnapshot {
|
export interface ProviderKeyStatusSnapshot {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||||
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
|
<span v-if="getQuotaText(key)">{{ shortenQuota(getQuotaText(key) || '') }}</span>
|
||||||
<span v-if="key.proxy?.node_id">独立代理</span>
|
<span v-if="key.proxy?.node_id">独立代理</span>
|
||||||
<span
|
<span
|
||||||
v-if="key.last_used_at"
|
v-if="key.last_used_at"
|
||||||
@@ -300,6 +300,7 @@ import {
|
|||||||
getOAuthStatusDisplay,
|
getOAuthStatusDisplay,
|
||||||
getOAuthStatusTitle,
|
getOAuthStatusTitle,
|
||||||
} from '@/utils/providerKeyStatus'
|
} from '@/utils/providerKeyStatus'
|
||||||
|
import { getQuotaDisplayText } from '@/utils/providerKeyQuota'
|
||||||
|
|
||||||
type QuickSelectorValue =
|
type QuickSelectorValue =
|
||||||
| 'banned'
|
| 'banned'
|
||||||
@@ -488,6 +489,10 @@ function formatRelativeTime(value: string): string {
|
|||||||
return `${Math.floor(diff / 86_400_000)}天前`
|
return `${Math.floor(diff / 86_400_000)}天前`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getQuotaText(key: PoolKeyDetail): string | null {
|
||||||
|
return getQuotaDisplayText(key, props.providerType)
|
||||||
|
}
|
||||||
|
|
||||||
function shortenQuota(raw: string): string {
|
function shortenQuota(raw: string): string {
|
||||||
return raw.split('|').map((segment) => {
|
return raw.split('|').map((segment) => {
|
||||||
let value = segment.trim()
|
let value = segment.trim()
|
||||||
|
|||||||
@@ -112,12 +112,14 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { testModel } from '@/api/endpoints/providers'
|
import { testModel } from '@/api/endpoints/providers'
|
||||||
|
import type { UpstreamMetadata, QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
metadata: Record<string, unknown> | null
|
metadata: UpstreamMetadata | null
|
||||||
|
quotaSnapshot?: QuotaStatusSnapshot | null
|
||||||
keyName: string
|
keyName: string
|
||||||
providerId?: string
|
providerId?: string
|
||||||
keyId?: string
|
keyId?: string
|
||||||
@@ -138,14 +140,85 @@ interface QuotaItem {
|
|||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
const testingModel = ref<string | null>(null)
|
const testingModel = ref<string | null>(null)
|
||||||
|
|
||||||
|
function getQuotaSnapshotUpdatedAt(quota: QuotaStatusSnapshot | null | undefined): number | undefined {
|
||||||
|
const updatedAt = quota?.updated_at ?? quota?.observed_at
|
||||||
|
return typeof updatedAt === 'number' ? updatedAt : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowLiveResetSeconds(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
window: QuotaWindowSnapshot | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!window) return null
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (typeof window.reset_at === 'number') {
|
||||||
|
return Math.max(window.reset_at - now, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.reset_seconds === 'number') {
|
||||||
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||||
|
const elapsed = typeof updatedAt === 'number' ? Math.max(now - updatedAt, 0) : 0
|
||||||
|
return Math.max(window.reset_seconds - elapsed, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefined): QuotaItem[] {
|
||||||
|
if (!quota) return []
|
||||||
|
|
||||||
|
const providerType = String(quota.provider_type || '').trim().toLowerCase()
|
||||||
|
if (providerType && providerType !== 'antigravity') return []
|
||||||
|
|
||||||
|
const windows = Array.isArray(quota.windows)
|
||||||
|
? quota.windows.filter(window => String(window?.scope || '').trim().toLowerCase() === 'model')
|
||||||
|
: []
|
||||||
|
if (windows.length === 0) return []
|
||||||
|
|
||||||
|
const items = windows
|
||||||
|
.map((window) => {
|
||||||
|
const model = String(window.model || window.label || window.code || '').trim()
|
||||||
|
if (!model) return null
|
||||||
|
|
||||||
|
const usedPercent =
|
||||||
|
typeof window.used_ratio === 'number'
|
||||||
|
? Math.max(Math.min(window.used_ratio * 100, 100), 0)
|
||||||
|
: typeof window.remaining_ratio === 'number'
|
||||||
|
? Math.max(Math.min((1 - window.remaining_ratio) * 100, 100), 0)
|
||||||
|
: null
|
||||||
|
if (usedPercent == null) return null
|
||||||
|
|
||||||
|
const remainingPercent =
|
||||||
|
typeof window.remaining_ratio === 'number'
|
||||||
|
? Math.max(Math.min(window.remaining_ratio * 100, 100), 0)
|
||||||
|
: Math.max(100 - usedPercent, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
label: String(window.label || window.model || model),
|
||||||
|
usedPercent,
|
||||||
|
remainingPercent,
|
||||||
|
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||||||
|
} satisfies QuotaItem
|
||||||
|
})
|
||||||
|
.filter((item): item is QuotaItem => item !== null)
|
||||||
|
|
||||||
|
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
const items = computed<QuotaItem[]>(() => {
|
const items = computed<QuotaItem[]>(() => {
|
||||||
|
const snapshotItems = buildItemsFromQuotaSnapshot(props.quotaSnapshot)
|
||||||
|
if (snapshotItems.length > 0) return snapshotItems
|
||||||
|
|
||||||
const antigravity = props.metadata?.antigravity
|
const antigravity = props.metadata?.antigravity
|
||||||
if (!antigravity || typeof antigravity !== 'object') return []
|
if (!antigravity || typeof antigravity !== 'object') return []
|
||||||
const quotaByModel = (antigravity as Record<string, unknown>).quota_by_model
|
const quotaByModel = antigravity.quota_by_model
|
||||||
if (!quotaByModel || typeof quotaByModel !== 'object') return []
|
if (!quotaByModel || typeof quotaByModel !== 'object') return []
|
||||||
|
|
||||||
const result: QuotaItem[] = []
|
const result: QuotaItem[] = []
|
||||||
for (const [model, rawInfo] of Object.entries(quotaByModel as Record<string, unknown>)) {
|
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
|
||||||
if (!model) continue
|
if (!model) continue
|
||||||
const info = (rawInfo || {}) as Record<string, unknown>
|
const info = (rawInfo || {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
|||||||
@@ -315,12 +315,12 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
<!-- Kiro 订阅类型标签 -->
|
<!-- Kiro 订阅类型标签 -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="provider.provider_type === 'kiro' && key.upstream_metadata?.kiro?.subscription_title"
|
v-if="provider.provider_type === 'kiro' && getKiroSubscriptionTitle(key)"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
:class="getOAuthPlanTypeClass(formatKiroSubscription(key.upstream_metadata?.kiro?.subscription_title))"
|
:class="getOAuthPlanTypeClass(formatKiroSubscription(getKiroSubscriptionTitle(key)))"
|
||||||
>
|
>
|
||||||
{{ formatKiroSubscription(key.upstream_metadata?.kiro?.subscription_title) }}
|
{{ formatKiroSubscription(getKiroSubscriptionTitle(key)) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<!-- Antigravity 账号未激活提示 -->
|
<!-- Antigravity 账号未激活提示 -->
|
||||||
<span
|
<span
|
||||||
v-if="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata))"
|
v-if="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && !hasAntigravityQuotaDisplayData(key)"
|
||||||
class="text-[10px] text-orange-500 dark:text-orange-400"
|
class="text-[10px] text-orange-500 dark:text-orange-400"
|
||||||
title="该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型"
|
title="该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型"
|
||||||
>
|
>
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
||||||
<div
|
<div
|
||||||
v-if="key.upstream_metadata && hasCodexQuotaData(key.upstream_metadata)"
|
v-if="hasCodexQuotaDisplayData(key)"
|
||||||
class="mt-2 p-2 bg-muted/30 rounded-md"
|
class="mt-2 p-2 bg-muted/30 rounded-md"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
@@ -561,82 +561,94 @@
|
|||||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="key.upstream_metadata.codex?.updated_at"
|
v-if="getCodexQuotaDisplay(key)?.updated_at"
|
||||||
class="text-[9px] text-muted-foreground/70"
|
class="text-[9px] text-muted-foreground/70"
|
||||||
>
|
>
|
||||||
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
{{ formatCodexUpdatedAt(getCodexQuotaDisplay(key)?.updated_at || 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getCodexCreditsSummary(getCodexQuotaDisplay(key))"
|
||||||
|
class="flex items-center justify-between text-[10px] mb-2"
|
||||||
|
>
|
||||||
|
<span class="text-muted-foreground">积分</span>
|
||||||
|
<span
|
||||||
|
class="font-medium"
|
||||||
|
:class="getCodexQuotaDisplay(key)?.has_credits === false ? 'text-red-600 dark:text-red-400' : 'text-foreground/80'"
|
||||||
|
>
|
||||||
|
{{ getCodexCreditsSummary(getCodexQuotaDisplay(key)) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
||||||
<div
|
<div
|
||||||
class="grid gap-3"
|
class="grid gap-3"
|
||||||
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
|
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
|
||||||
>
|
>
|
||||||
<!-- 周限额 -->
|
<!-- 周限额 -->
|
||||||
<div v-if="key.upstream_metadata.codex?.primary_used_percent !== undefined">
|
<div v-if="getCodexQuotaDisplay(key)?.primary_used_percent !== undefined">
|
||||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
<span class="text-muted-foreground">周限额</span>
|
<span class="text-muted-foreground">周限额</span>
|
||||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.primary_used_percent)">
|
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.primary_used_percent || 0)">
|
||||||
{{ (100 - key.upstream_metadata.codex.primary_used_percent).toFixed(1) }}%
|
{{ (100 - (getCodexQuotaDisplay(key)?.primary_used_percent || 0)).toFixed(1) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.primary_used_percent)"
|
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
||||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.primary_used_percent, 0)}%` }"
|
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.primary_used_percent || 0), 0)}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="(key.upstream_metadata.codex.primary_reset_at || key.upstream_metadata.codex.primary_reset_seconds) && shouldStartCodexResetCountdown(key.upstream_metadata.codex.primary_used_percent)"
|
v-if="(getCodexQuotaDisplay(key)?.primary_reset_at || getCodexQuotaDisplay(key)?.primary_reset_seconds) && shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
||||||
class="text-[9px] mt-0.5 tabular-nums"
|
class="text-[9px] mt-0.5 tabular-nums"
|
||||||
:class="getResetCountdownClass(
|
:class="getResetCountdownClass(
|
||||||
key.upstream_metadata.codex.primary_reset_at,
|
getCodexQuotaDisplay(key)?.primary_reset_at,
|
||||||
key.upstream_metadata.codex.primary_reset_seconds,
|
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
||||||
key.upstream_metadata.codex.updated_at,
|
getCodexQuotaDisplay(key)?.updated_at,
|
||||||
key.upstream_metadata.codex.primary_used_percent
|
getCodexQuotaDisplay(key)?.primary_used_percent
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
{{ getResetCountdownText(
|
{{ getResetCountdownText(
|
||||||
key.upstream_metadata.codex.primary_reset_at,
|
getCodexQuotaDisplay(key)?.primary_reset_at,
|
||||||
key.upstream_metadata.codex.primary_reset_seconds,
|
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
||||||
key.upstream_metadata.codex.updated_at,
|
getCodexQuotaDisplay(key)?.updated_at,
|
||||||
key.upstream_metadata.codex.primary_used_percent
|
getCodexQuotaDisplay(key)?.primary_used_percent
|
||||||
) }}
|
) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 5H限额(仅 Team/Plus/Enterprise 显示) -->
|
<!-- 5H限额(仅 Team/Plus/Enterprise 显示) -->
|
||||||
<div v-if="isCodexTeamPlan(key) && key.upstream_metadata.codex?.secondary_used_percent !== undefined">
|
<div v-if="isCodexTeamPlan(key) && getCodexQuotaDisplay(key)?.secondary_used_percent !== undefined">
|
||||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
<span class="text-muted-foreground">5H限额</span>
|
<span class="text-muted-foreground">5H限额</span>
|
||||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.secondary_used_percent)">
|
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)">
|
||||||
{{ (100 - key.upstream_metadata.codex.secondary_used_percent).toFixed(1) }}%
|
{{ (100 - (getCodexQuotaDisplay(key)?.secondary_used_percent || 0)).toFixed(1) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.secondary_used_percent)"
|
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
||||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.secondary_used_percent, 0)}%` }"
|
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.secondary_used_percent || 0), 0)}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="shouldStartCodexResetCountdown(key.upstream_metadata.codex.secondary_used_percent)"
|
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
||||||
class="text-[9px] mt-0.5 tabular-nums"
|
class="text-[9px] mt-0.5 tabular-nums"
|
||||||
:class="getResetCountdownClass(
|
:class="getResetCountdownClass(
|
||||||
key.upstream_metadata.codex.secondary_reset_at,
|
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
||||||
key.upstream_metadata.codex.secondary_reset_seconds,
|
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
||||||
key.upstream_metadata.codex.updated_at,
|
getCodexQuotaDisplay(key)?.updated_at,
|
||||||
key.upstream_metadata.codex.secondary_used_percent
|
getCodexQuotaDisplay(key)?.secondary_used_percent
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
<template v-if="key.upstream_metadata.codex.secondary_reset_at || key.upstream_metadata.codex.secondary_reset_seconds">
|
<template v-if="getCodexQuotaDisplay(key)?.secondary_reset_at || getCodexQuotaDisplay(key)?.secondary_reset_seconds">
|
||||||
{{ getResetCountdownText(
|
{{ getResetCountdownText(
|
||||||
key.upstream_metadata.codex.secondary_reset_at,
|
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
||||||
key.upstream_metadata.codex.secondary_reset_seconds,
|
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
||||||
key.upstream_metadata.codex.updated_at,
|
getCodexQuotaDisplay(key)?.updated_at,
|
||||||
key.upstream_metadata.codex.secondary_used_percent
|
getCodexQuotaDisplay(key)?.secondary_used_percent
|
||||||
) }}
|
) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -648,13 +660,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
||||||
<div
|
<div
|
||||||
v-if="provider.provider_type === 'antigravity' && key.upstream_metadata && (hasAntigravityQuotaData(key.upstream_metadata) || isAntigravityForbidden(key.upstream_metadata))"
|
v-if="provider.provider_type === 'antigravity' && (hasAntigravityQuotaDisplayData(key) || isAntigravityForbiddenKey(key))"
|
||||||
class="mt-2 p-2 rounded-md"
|
class="mt-2 p-2 rounded-md"
|
||||||
:class="isAntigravityForbidden(key.upstream_metadata) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
:class="isAntigravityForbiddenKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||||
>
|
>
|
||||||
<!-- 封禁状态显示 -->
|
<!-- 封禁状态显示 -->
|
||||||
<div
|
<div
|
||||||
v-if="isAntigravityForbidden(key.upstream_metadata)"
|
v-if="isAntigravityForbiddenKey(key)"
|
||||||
class="flex items-center gap-2 text-destructive"
|
class="flex items-center gap-2 text-destructive"
|
||||||
>
|
>
|
||||||
<ShieldX class="w-4 h-4 shrink-0" />
|
<ShieldX class="w-4 h-4 shrink-0" />
|
||||||
@@ -663,18 +675,18 @@
|
|||||||
账户访问被禁止
|
账户访问被禁止
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="key.upstream_metadata.antigravity?.forbidden_reason"
|
v-if="getAntigravityForbiddenReason(key)"
|
||||||
class="text-[10px] text-destructive/80 truncate"
|
class="text-[10px] text-destructive/80 truncate"
|
||||||
:title="key.upstream_metadata.antigravity?.forbidden_reason"
|
:title="getAntigravityForbiddenReason(key)"
|
||||||
>
|
>
|
||||||
{{ key.upstream_metadata.antigravity?.forbidden_reason }}
|
{{ getAntigravityForbiddenReason(key) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="key.upstream_metadata.antigravity?.forbidden_at"
|
v-if="getAntigravityForbiddenAt(key)"
|
||||||
class="text-[9px] text-destructive/60 shrink-0"
|
class="text-[9px] text-destructive/60 shrink-0"
|
||||||
>
|
>
|
||||||
{{ formatBanTimestamp(key.upstream_metadata.antigravity?.forbidden_at) }}
|
{{ formatBanTimestamp(getAntigravityForbiddenAt(key)) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 正常配额显示 -->
|
<!-- 正常配额显示 -->
|
||||||
@@ -687,16 +699,16 @@
|
|||||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="key.upstream_metadata.antigravity?.updated_at"
|
v-if="getAntigravityQuotaUpdatedAt(key)"
|
||||||
class="text-[9px] text-muted-foreground/70"
|
class="text-[9px] text-muted-foreground/70"
|
||||||
>
|
>
|
||||||
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
{{ formatAntigravityUpdatedAt(getAntigravityQuotaUpdatedAt(key) || 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div
|
<div
|
||||||
v-for="group in getAntigravityQuotaSummary(key.upstream_metadata)"
|
v-for="group in getAntigravityQuotaSummaryForKey(key)"
|
||||||
:key="group.key"
|
:key="group.key"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
@@ -734,13 +746,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
||||||
<div
|
<div
|
||||||
v-if="provider.provider_type === 'kiro' && key.upstream_metadata && (hasKiroQuotaData(key.upstream_metadata) || isKiroBanned(key.upstream_metadata))"
|
v-if="provider.provider_type === 'kiro' && (hasKiroQuotaDisplayData(key) || isKiroBannedKey(key))"
|
||||||
class="mt-2 p-2 rounded-md"
|
class="mt-2 p-2 rounded-md"
|
||||||
:class="isKiroBanned(key.upstream_metadata) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
:class="isKiroBannedKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||||
>
|
>
|
||||||
<!-- 封禁状态显示 -->
|
<!-- 封禁状态显示 -->
|
||||||
<div
|
<div
|
||||||
v-if="isKiroBanned(key.upstream_metadata)"
|
v-if="isKiroBannedKey(key)"
|
||||||
class="flex items-center gap-2 text-destructive"
|
class="flex items-center gap-2 text-destructive"
|
||||||
>
|
>
|
||||||
<ShieldX class="w-4 h-4 shrink-0" />
|
<ShieldX class="w-4 h-4 shrink-0" />
|
||||||
@@ -749,18 +761,18 @@
|
|||||||
账户已封禁
|
账户已封禁
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="key.upstream_metadata.kiro?.ban_reason"
|
v-if="getKiroQuotaDisplay(key)?.ban_reason"
|
||||||
class="text-[10px] text-destructive/80 truncate"
|
class="text-[10px] text-destructive/80 truncate"
|
||||||
:title="key.upstream_metadata.kiro?.ban_reason"
|
:title="getKiroQuotaDisplay(key)?.ban_reason"
|
||||||
>
|
>
|
||||||
{{ key.upstream_metadata.kiro?.ban_reason }}
|
{{ getKiroQuotaDisplay(key)?.ban_reason }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="key.upstream_metadata.kiro?.banned_at"
|
v-if="getKiroQuotaDisplay(key)?.banned_at"
|
||||||
class="text-[9px] text-destructive/60 shrink-0"
|
class="text-[9px] text-destructive/60 shrink-0"
|
||||||
>
|
>
|
||||||
{{ formatBanTimestamp(key.upstream_metadata.kiro?.banned_at) }}
|
{{ formatBanTimestamp(getKiroQuotaDisplay(key)?.banned_at) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 正常配额显示 -->
|
<!-- 正常配额显示 -->
|
||||||
@@ -773,10 +785,10 @@
|
|||||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="key.upstream_metadata.kiro?.updated_at"
|
v-if="getKiroQuotaDisplay(key)?.updated_at"
|
||||||
class="text-[9px] text-muted-foreground/70"
|
class="text-[9px] text-muted-foreground/70"
|
||||||
>
|
>
|
||||||
{{ formatKiroUpdatedAt(key.upstream_metadata.kiro?.updated_at) }}
|
{{ formatKiroUpdatedAt(getKiroQuotaDisplay(key)?.updated_at || 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -786,24 +798,24 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
<span class="text-muted-foreground">使用额度</span>
|
<span class="text-muted-foreground">使用额度</span>
|
||||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.kiro?.usage_percentage || 0)">
|
<span :class="getQuotaRemainingClass(getKiroQuotaDisplay(key)?.usage_percentage || 0)">
|
||||||
{{ (100 - (key.upstream_metadata.kiro?.usage_percentage || 0)).toFixed(1) }}%
|
{{ (100 - (getKiroQuotaDisplay(key)?.usage_percentage || 0)).toFixed(1) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.kiro?.usage_percentage || 0)"
|
:class="getQuotaRemainingBarColor(getKiroQuotaDisplay(key)?.usage_percentage || 0)"
|
||||||
:style="{ width: `${Math.max(100 - (key.upstream_metadata.kiro?.usage_percentage || 0), 0)}%` }"
|
:style="{ width: `${Math.max(100 - (getKiroQuotaDisplay(key)?.usage_percentage || 0), 0)}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
||||||
<span>
|
<span>
|
||||||
{{ formatKiroUsage(key.upstream_metadata.kiro?.current_usage) }} /
|
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.current_usage) }} /
|
||||||
{{ formatKiroUsage(key.upstream_metadata.kiro?.usage_limit) }}
|
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.usage_limit) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="key.upstream_metadata.kiro?.next_reset_at">
|
<span v-if="getKiroQuotaDisplay(key)?.next_reset_at">
|
||||||
{{ formatKiroResetTime(key.upstream_metadata.kiro?.next_reset_at) }}重置
|
{{ formatKiroResetTime(getKiroQuotaDisplay(key)?.next_reset_at) }}重置
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1062,6 +1074,7 @@
|
|||||||
v-if="antigravityQuotaDialogKey"
|
v-if="antigravityQuotaDialogKey"
|
||||||
:open="antigravityQuotaDialogOpen"
|
:open="antigravityQuotaDialogOpen"
|
||||||
:metadata="antigravityQuotaDialogKey.upstream_metadata"
|
:metadata="antigravityQuotaDialogKey.upstream_metadata"
|
||||||
|
:quota-snapshot="antigravityQuotaDialogKey.status_snapshot?.quota ?? null"
|
||||||
:key-name="antigravityQuotaDialogKey.name || '未命名密钥'"
|
:key-name="antigravityQuotaDialogKey.name || '未命名密钥'"
|
||||||
:provider-id="providerId"
|
:provider-id="providerId"
|
||||||
:key-id="antigravityQuotaDialogKey.id"
|
:key-id="antigravityQuotaDialogKey.id"
|
||||||
@@ -1152,7 +1165,15 @@ import {
|
|||||||
API_FORMAT_SHORT,
|
API_FORMAT_SHORT,
|
||||||
sortApiFormats,
|
sortApiFormats,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
import type {
|
||||||
|
UpstreamMetadata,
|
||||||
|
AntigravityModelQuota,
|
||||||
|
AntigravityUpstreamMetadata,
|
||||||
|
CodexUpstreamMetadata,
|
||||||
|
KiroUpstreamMetadata,
|
||||||
|
QuotaStatusSnapshot,
|
||||||
|
QuotaWindowSnapshot,
|
||||||
|
} from '@/api/endpoints/types'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||||
@@ -1786,23 +1807,221 @@ const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
|||||||
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
||||||
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
||||||
|
|
||||||
// 检查 Codex 是否有配额数据
|
function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefined): boolean {
|
||||||
function hasCodexQuotaData(meta: UpstreamMetadata | null | undefined): boolean {
|
if (!quota) return false
|
||||||
if (!meta?.codex) return false
|
return Boolean(
|
||||||
// Codex 配额数据存储在 codex 子对象中
|
(typeof quota.code === 'string' && quota.code.trim().toLowerCase() !== 'unknown')
|
||||||
return meta.codex.primary_used_percent !== undefined || meta.codex.secondary_used_percent !== undefined
|
|| quota.updated_at != null
|
||||||
|
|| quota.observed_at != null
|
||||||
|
|| quota.usage_ratio != null
|
||||||
|
|| (Array.isArray(quota.windows) && quota.windows.length > 0)
|
||||||
|
|| quota.credits,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 Kiro 是否有配额数据
|
function getQuotaSnapshotForProvider(
|
||||||
function hasKiroQuotaData(meta: UpstreamMetadata | null | undefined): boolean {
|
key: EndpointAPIKey,
|
||||||
if (!meta?.kiro) return false
|
providerType: 'codex' | 'kiro' | 'antigravity' | 'gemini_cli',
|
||||||
return meta.kiro.usage_percentage !== undefined || meta.kiro.usage_limit !== undefined
|
): QuotaStatusSnapshot | null {
|
||||||
|
const quota = key.status_snapshot?.quota
|
||||||
|
if (!quota) return null
|
||||||
|
|
||||||
|
const snapshotProviderType = quota.provider_type?.trim().toLowerCase()
|
||||||
|
if (snapshotProviderType) {
|
||||||
|
return snapshotProviderType === providerType ? quota : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return quotaSnapshotHasDisplayData(quota) ? quota : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 Kiro 账户是否被封禁
|
function getQuotaSnapshotUpdatedAt(quota: QuotaStatusSnapshot | null | undefined): number | undefined {
|
||||||
function isKiroBanned(meta: UpstreamMetadata | null | undefined): boolean {
|
const updatedAt = quota?.updated_at ?? quota?.observed_at
|
||||||
if (!meta?.kiro) return false
|
return typeof updatedAt === 'number' ? updatedAt : undefined
|
||||||
return meta.kiro.is_banned === true
|
}
|
||||||
|
|
||||||
|
function getQuotaWindow(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
code: string,
|
||||||
|
): QuotaWindowSnapshot | null {
|
||||||
|
const windows = quota?.windows
|
||||||
|
if (!Array.isArray(windows)) return null
|
||||||
|
return windows.find(window => String(window?.code || '').trim().toLowerCase() === code.trim().toLowerCase()) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowUsedPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||||
|
if (!window) return undefined
|
||||||
|
if (typeof window.used_ratio === 'number') {
|
||||||
|
return Math.max(Math.min(window.used_ratio * 100, 100), 0)
|
||||||
|
}
|
||||||
|
if (typeof window.remaining_ratio === 'number') {
|
||||||
|
return Math.max(Math.min((1 - window.remaining_ratio) * 100, 100), 0)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowRemainingPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||||
|
if (!window) return undefined
|
||||||
|
if (typeof window.remaining_ratio === 'number') {
|
||||||
|
return Math.max(Math.min(window.remaining_ratio * 100, 100), 0)
|
||||||
|
}
|
||||||
|
if (typeof window.used_ratio === 'number') {
|
||||||
|
return Math.max(Math.min((1 - window.used_ratio) * 100, 100), 0)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowResetAt(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||||
|
return typeof window?.reset_at === 'number' ? window.reset_at : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowResetSeconds(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||||
|
return typeof window?.reset_seconds === 'number' ? window.reset_seconds : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowByScope(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
scope: string,
|
||||||
|
): QuotaWindowSnapshot[] {
|
||||||
|
const windows = quota?.windows
|
||||||
|
if (!Array.isArray(windows)) return []
|
||||||
|
return windows.filter(window => String(window?.scope || '').trim().toLowerCase() === scope.trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowLiveResetSeconds(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
window: QuotaWindowSnapshot | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!window) return null
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (typeof window.reset_at === 'number') {
|
||||||
|
return Math.max(window.reset_at - now, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.reset_seconds === 'number') {
|
||||||
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||||
|
const elapsed = typeof updatedAt === 'number' ? Math.max(now - updatedAt, 0) : 0
|
||||||
|
return Math.max(window.reset_seconds - elapsed, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
|
||||||
|
const quota = getQuotaSnapshotForProvider(key, 'codex')
|
||||||
|
if (!quota) return null
|
||||||
|
|
||||||
|
const display: CodexUpstreamMetadata = {}
|
||||||
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||||
|
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||||||
|
if (quota.plan_type) display.plan_type = quota.plan_type
|
||||||
|
|
||||||
|
const primaryWindow = getQuotaWindow(quota, 'weekly')
|
||||||
|
const primaryUsedPercent = getQuotaWindowUsedPercent(primaryWindow)
|
||||||
|
if (primaryUsedPercent !== undefined) display.primary_used_percent = primaryUsedPercent
|
||||||
|
const primaryResetAt = getQuotaWindowResetAt(primaryWindow)
|
||||||
|
if (primaryResetAt !== undefined) display.primary_reset_at = primaryResetAt
|
||||||
|
const primaryResetSeconds = getQuotaWindowResetSeconds(primaryWindow)
|
||||||
|
if (primaryResetSeconds !== undefined) display.primary_reset_seconds = primaryResetSeconds
|
||||||
|
if (typeof primaryWindow?.window_minutes === 'number') {
|
||||||
|
display.primary_window_minutes = primaryWindow.window_minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryWindow = getQuotaWindow(quota, '5h')
|
||||||
|
const secondaryUsedPercent = getQuotaWindowUsedPercent(secondaryWindow)
|
||||||
|
if (secondaryUsedPercent !== undefined) display.secondary_used_percent = secondaryUsedPercent
|
||||||
|
const secondaryResetAt = getQuotaWindowResetAt(secondaryWindow)
|
||||||
|
if (secondaryResetAt !== undefined) display.secondary_reset_at = secondaryResetAt
|
||||||
|
const secondaryResetSeconds = getQuotaWindowResetSeconds(secondaryWindow)
|
||||||
|
if (secondaryResetSeconds !== undefined) display.secondary_reset_seconds = secondaryResetSeconds
|
||||||
|
if (typeof secondaryWindow?.window_minutes === 'number') {
|
||||||
|
display.secondary_window_minutes = secondaryWindow.window_minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof quota.credits?.has_credits === 'boolean') {
|
||||||
|
display.has_credits = quota.credits.has_credits
|
||||||
|
}
|
||||||
|
if (typeof quota.credits?.balance === 'number') {
|
||||||
|
display.credits_balance = quota.credits.balance
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(display).length > 0 ? display : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||||
|
const codex = getCodexQuotaDisplay(key)
|
||||||
|
return !!codex && (
|
||||||
|
codex.primary_used_percent !== undefined
|
||||||
|
|| codex.secondary_used_percent !== undefined
|
||||||
|
|| codex.has_credits !== undefined
|
||||||
|
|| codex.credits_balance !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexCreditsSummary(codex: CodexUpstreamMetadata | null | undefined): string | null {
|
||||||
|
if (!codex) return null
|
||||||
|
if (codex.has_credits === true && typeof codex.credits_balance === 'number') {
|
||||||
|
return `积分 ${codex.credits_balance.toFixed(2)}`
|
||||||
|
}
|
||||||
|
if (codex.has_credits === true) {
|
||||||
|
return '有积分'
|
||||||
|
}
|
||||||
|
if (codex.has_credits === false) {
|
||||||
|
return '无可用积分'
|
||||||
|
}
|
||||||
|
if (typeof codex.credits_balance === 'number') {
|
||||||
|
return `积分 ${codex.credits_balance.toFixed(2)}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKiroQuotaDisplay(key: EndpointAPIKey): KiroUpstreamMetadata | null {
|
||||||
|
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
||||||
|
if (!quota) return null
|
||||||
|
|
||||||
|
const display: KiroUpstreamMetadata = {}
|
||||||
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||||
|
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||||||
|
if (quota.plan_type) display.subscription_title = quota.plan_type
|
||||||
|
|
||||||
|
if (String(quota.code || '').trim().toLowerCase() === 'banned') {
|
||||||
|
display.is_banned = true
|
||||||
|
if (quota.reason) display.ban_reason = quota.reason
|
||||||
|
if (updatedAt !== undefined) display.banned_at = updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageWindow =
|
||||||
|
getQuotaWindow(quota, 'usage')
|
||||||
|
?? getQuotaWindowByScope(quota, 'account')[0]
|
||||||
|
?? null
|
||||||
|
if (usageWindow) {
|
||||||
|
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
|
||||||
|
if (usedPercent !== undefined) display.usage_percentage = usedPercent
|
||||||
|
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
|
||||||
|
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
|
||||||
|
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
|
||||||
|
|
||||||
|
const nextResetAt =
|
||||||
|
getQuotaWindowResetAt(usageWindow)
|
||||||
|
?? (() => {
|
||||||
|
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
|
||||||
|
if (updatedAt === undefined || resetSeconds === undefined) return undefined
|
||||||
|
return updatedAt + resetSeconds
|
||||||
|
})()
|
||||||
|
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(display).length > 0 ? display : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||||
|
const kiro = getKiroQuotaDisplay(key)
|
||||||
|
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKiroBannedKey(key: EndpointAPIKey): boolean {
|
||||||
|
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
||||||
|
return String(quota?.code || '').trim().toLowerCase() === 'banned'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
|
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
|
||||||
@@ -1817,10 +2036,22 @@ function formatBanTimestamp(timestamp: number | undefined): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 Antigravity 账户是否被禁止访问
|
function isAntigravityForbiddenKey(key: EndpointAPIKey): boolean {
|
||||||
function isAntigravityForbidden(meta: UpstreamMetadata | null | undefined): boolean {
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||||
if (!meta?.antigravity) return false
|
return String(quota?.code || '').trim().toLowerCase() === 'forbidden'
|
||||||
return meta.antigravity.is_forbidden === true
|
}
|
||||||
|
|
||||||
|
function getAntigravityForbiddenReason(key: EndpointAPIKey): string | undefined {
|
||||||
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||||
|
return quota?.reason || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityForbiddenAt(key: EndpointAPIKey): number | undefined {
|
||||||
|
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
|
||||||
|
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化 Kiro 更新时间
|
// 格式化 Kiro 更新时间
|
||||||
@@ -1877,6 +2108,10 @@ function formatKiroSubscription(title: string | undefined): string {
|
|||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getKiroSubscriptionTitle(key: EndpointAPIKey): string | undefined {
|
||||||
|
return getKiroQuotaDisplay(key)?.subscription_title
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAutoRefreshCodexQuota(): boolean {
|
function shouldAutoRefreshCodexQuota(): boolean {
|
||||||
if (provider.value?.provider_type !== 'codex') return false
|
if (provider.value?.provider_type !== 'codex') return false
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -1886,13 +2121,12 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
|||||||
|
|
||||||
if (isTokenExpiringSoon(key, now)) return true
|
if (isTokenExpiringSoon(key, now)) return true
|
||||||
|
|
||||||
const meta: UpstreamMetadata | null | undefined = key.upstream_metadata
|
|
||||||
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
||||||
if (!hasCodexQuotaData(meta)) {
|
if (!hasCodexQuotaDisplayData(key)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// 配额数据超过 5 分钟未更新,也触发刷新
|
// 配额数据超过 5 分钟未更新,也触发刷新
|
||||||
const updatedAt = meta?.codex?.updated_at
|
const updatedAt = getCodexQuotaDisplay(key)?.updated_at
|
||||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1920,14 +2154,11 @@ function shouldAutoRefreshAntigravityQuota(): boolean {
|
|||||||
|
|
||||||
if (isTokenExpiringSoon(key, now)) return true
|
if (isTokenExpiringSoon(key, now)) return true
|
||||||
|
|
||||||
const meta = key.upstream_metadata
|
|
||||||
const updatedAt = meta?.antigravity?.updated_at
|
|
||||||
const quotaByModel = meta?.antigravity?.quota_by_model
|
|
||||||
|
|
||||||
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key)
|
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key)
|
||||||
if (!quotaByModel || typeof quotaByModel !== 'object' || Object.keys(quotaByModel).length === 0) {
|
if (!hasAntigravityQuotaDisplayData(key)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
const updatedAt = getAntigravityQuotaUpdatedAt(key)
|
||||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1945,13 +2176,12 @@ function shouldAutoRefreshKiroQuota(): boolean {
|
|||||||
|
|
||||||
if (isTokenExpiringSoon(key, now)) return true
|
if (isTokenExpiringSoon(key, now)) return true
|
||||||
|
|
||||||
const meta = key.upstream_metadata
|
|
||||||
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
||||||
if (!hasKiroQuotaData(meta)) {
|
if (!hasKiroQuotaDisplayData(key)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// 配额数据超过 5 分钟未更新,也触发刷新
|
// 配额数据超过 5 分钟未更新,也触发刷新
|
||||||
const updatedAt = meta?.kiro?.updated_at
|
const updatedAt = getKiroQuotaDisplay(key)?.updated_at
|
||||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1960,15 +2190,81 @@ function shouldAutoRefreshKiroQuota(): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultQuotaSnapshot(): QuotaStatusSnapshot {
|
||||||
|
return {
|
||||||
|
code: 'unknown',
|
||||||
|
exhausted: false,
|
||||||
|
usage_ratio: null,
|
||||||
|
updated_at: null,
|
||||||
|
reset_seconds: null,
|
||||||
|
plan_type: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapQuotaMetadataForProvider(
|
||||||
|
providerType: string,
|
||||||
|
metadata: Record<string, unknown> | undefined,
|
||||||
|
): UpstreamMetadata | null {
|
||||||
|
if (!metadata) return null
|
||||||
|
if (providerType in metadata) {
|
||||||
|
return metadata as UpstreamMetadata
|
||||||
|
}
|
||||||
|
return { [providerType]: metadata } as UpstreamMetadata
|
||||||
|
}
|
||||||
|
|
||||||
// 将配额刷新结果就地应用到现有 key 上,避免重新拉列表导致分页重置
|
// 将配额刷新结果就地应用到现有 key 上,避免重新拉列表导致分页重置
|
||||||
function applyQuotaResults(results: { key_id: string; status: string; metadata?: Record<string, unknown> }[]) {
|
function applyQuotaResults(
|
||||||
|
results: { key_id: string; status: string; metadata?: Record<string, unknown>; quota_snapshot?: QuotaStatusSnapshot }[],
|
||||||
|
): number {
|
||||||
|
const providerType = provider.value?.provider_type
|
||||||
|
if (!providerType) return 0
|
||||||
|
|
||||||
|
let applied = 0
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.status !== 'success' || !r.metadata) continue
|
|
||||||
const target = providerKeys.value.find(k => k.id === r.key_id)
|
const target = providerKeys.value.find(k => k.id === r.key_id)
|
||||||
if (target) {
|
if (!target) continue
|
||||||
target.upstream_metadata = { ...target.upstream_metadata, ...r.metadata } as typeof target.upstream_metadata
|
|
||||||
|
let changed = false
|
||||||
|
const wrappedMetadata = wrapQuotaMetadataForProvider(providerType, r.metadata)
|
||||||
|
if (wrappedMetadata) {
|
||||||
|
target.upstream_metadata = { ...target.upstream_metadata, ...wrappedMetadata } as typeof target.upstream_metadata
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.quota_snapshot) {
|
||||||
|
target.status_snapshot = {
|
||||||
|
oauth: target.status_snapshot?.oauth ?? {
|
||||||
|
code: 'none',
|
||||||
|
label: null,
|
||||||
|
reason: null,
|
||||||
|
expires_at: null,
|
||||||
|
invalid_at: null,
|
||||||
|
source: null,
|
||||||
|
requires_reauth: false,
|
||||||
|
expiring_soon: false,
|
||||||
|
},
|
||||||
|
account: target.status_snapshot?.account ?? {
|
||||||
|
code: 'ok',
|
||||||
|
label: null,
|
||||||
|
reason: null,
|
||||||
|
blocked: false,
|
||||||
|
source: null,
|
||||||
|
recoverable: false,
|
||||||
|
},
|
||||||
|
quota: {
|
||||||
|
...defaultQuotaSnapshot(),
|
||||||
|
...(target.status_snapshot?.quota ?? {}),
|
||||||
|
...r.quota_snapshot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
applied += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return applied
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用的自动刷新配额函数(支持 Codex、Antigravity 和 Kiro)
|
// 通用的自动刷新配额函数(支持 Codex、Antigravity 和 Kiro)
|
||||||
@@ -1992,20 +2288,18 @@ async function autoRefreshQuotaInBackground() {
|
|||||||
|
|
||||||
let hadCachedQuota = false
|
let hadCachedQuota = false
|
||||||
if (providerType === 'codex') {
|
if (providerType === 'codex') {
|
||||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaData(key.upstream_metadata))
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key))
|
||||||
} else if (providerType === 'antigravity') {
|
} else if (providerType === 'antigravity') {
|
||||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && key.upstream_metadata && hasAntigravityQuotaData(key.upstream_metadata))
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
|
||||||
} else if (providerType === 'kiro') {
|
} else if (providerType === 'kiro') {
|
||||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaData(key.upstream_metadata))
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshingQuota.value = true
|
refreshingQuota.value = true
|
||||||
try {
|
try {
|
||||||
const result = await refreshProviderQuota(props.providerId)
|
const result = await refreshProviderQuota(props.providerId)
|
||||||
if (result.success > 0) {
|
const applied = applyQuotaResults(result.results)
|
||||||
// 就地更新 key 的 upstream_metadata,避免重新拉列表导致分页重置
|
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
|
||||||
applyQuotaResults(result.results)
|
|
||||||
} else if (!hadCachedQuota && providerType === 'antigravity') {
|
|
||||||
showError('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)', '提示')
|
showError('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)', '提示')
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -2022,18 +2316,16 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
|||||||
antigravityQuotaDialogOpen.value = true
|
antigravityQuotaDialogOpen.value = true
|
||||||
|
|
||||||
// 没有配额数据时主动获取
|
// 没有配额数据时主动获取
|
||||||
if (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata)) {
|
if (!hasAntigravityQuotaDisplayData(key)) {
|
||||||
if (refreshingQuota.value) return
|
if (refreshingQuota.value) return
|
||||||
refreshingQuota.value = true
|
refreshingQuota.value = true
|
||||||
try {
|
try {
|
||||||
const result = await refreshProviderQuota(props.providerId)
|
const result = await refreshProviderQuota(props.providerId)
|
||||||
if (result.success > 0) {
|
applyQuotaResults(result.results)
|
||||||
applyQuotaResults(result.results)
|
// 更新弹窗引用的 key 数据
|
||||||
// 更新弹窗引用的 key 数据
|
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
|
||||||
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
|
if (updated) {
|
||||||
if (updated) {
|
antigravityQuotaDialogKey.value = updated.key
|
||||||
antigravityQuotaDialogKey.value = updated.key
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 静默失败,弹窗会显示"暂无配额数据"
|
// 静默失败,弹窗会显示"暂无配额数据"
|
||||||
@@ -2478,7 +2770,7 @@ function getQuotaRemainingBarColor(usedPercent: number): string {
|
|||||||
|
|
||||||
// 判断是否为 Codex Team/Plus/Enterprise 账号(有 5H 限额,显示 3 列)
|
// 判断是否为 Codex Team/Plus/Enterprise 账号(有 5H 限额,显示 3 列)
|
||||||
function isCodexTeamPlan(key: EndpointAPIKey): boolean {
|
function isCodexTeamPlan(key: EndpointAPIKey): boolean {
|
||||||
const planType = key.oauth_plan_type?.toLowerCase() || key.upstream_metadata?.codex?.plan_type?.toLowerCase()
|
const planType = key.oauth_plan_type?.toLowerCase() || getCodexQuotaDisplay(key)?.plan_type?.toLowerCase()
|
||||||
// Free 账号返回 false(2 列),其他所有账号返回 true(3 列)
|
// Free 账号返回 false(2 列),其他所有账号返回 true(3 列)
|
||||||
return planType !== undefined && planType !== 'free'
|
return planType !== undefined && planType !== 'free'
|
||||||
}
|
}
|
||||||
@@ -2496,6 +2788,14 @@ function hasAntigravityQuotaData(metadata: UpstreamMetadata | null | undefined):
|
|||||||
return !!quotaByModel && typeof quotaByModel === 'object' && Object.keys(quotaByModel).length > 0
|
return !!quotaByModel && typeof quotaByModel === 'object' && Object.keys(quotaByModel).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||||
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||||
|
if (Array.isArray(quota?.windows) && quota.windows.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasAntigravityQuotaData(key.upstream_metadata)
|
||||||
|
}
|
||||||
|
|
||||||
function formatUpdatedAt(updatedAt: number): string {
|
function formatUpdatedAt(updatedAt: number): string {
|
||||||
if (!updatedAt || typeof updatedAt !== 'number') return ''
|
if (!updatedAt || typeof updatedAt !== 'number') return ''
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -2564,6 +2864,45 @@ function getAntigravityQuotaItems(metadata: UpstreamMetadata | null | undefined)
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQuotaItem[] {
|
||||||
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||||
|
const windows = getQuotaWindowByScope(quota, 'model')
|
||||||
|
if (!quota || windows.length === 0) return []
|
||||||
|
|
||||||
|
const items = windows
|
||||||
|
.map((window) => {
|
||||||
|
const model = String(window.model || window.label || window.code || '').trim()
|
||||||
|
if (!model) return null
|
||||||
|
|
||||||
|
const usedPercent = getQuotaWindowUsedPercent(window)
|
||||||
|
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||||
|
if (usedPercent === undefined && remainingPercent === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUsedPercent =
|
||||||
|
usedPercent !== undefined
|
||||||
|
? usedPercent
|
||||||
|
: Math.max(100 - (remainingPercent ?? 0), 0)
|
||||||
|
const normalizedRemainingPercent =
|
||||||
|
remainingPercent !== undefined
|
||||||
|
? remainingPercent
|
||||||
|
: Math.max(100 - normalizedUsedPercent, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
label: String(window.label || window.model || model),
|
||||||
|
usedPercent: normalizedUsedPercent,
|
||||||
|
remainingPercent: normalizedRemainingPercent,
|
||||||
|
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||||||
|
} satisfies AntigravityQuotaItem
|
||||||
|
})
|
||||||
|
.filter((item): item is AntigravityQuotaItem => item !== null)
|
||||||
|
|
||||||
|
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// Antigravity 配额分组定义(按匹配优先级排列,具体规则在前)
|
// Antigravity 配额分组定义(按匹配优先级排列,具体规则在前)
|
||||||
interface AntigravityQuotaGroup {
|
interface AntigravityQuotaGroup {
|
||||||
key: string
|
key: string
|
||||||
@@ -2633,6 +2972,53 @@ function getAntigravityQuotaSummary(metadata: UpstreamMetadata | null | undefine
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAntigravityQuotaSummaryForKey(key: EndpointAPIKey): AntigravityQuotaSummaryItem[] {
|
||||||
|
const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key)
|
||||||
|
if (snapshotItems.length > 0) {
|
||||||
|
const groupMap = new Map<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
|
||||||
|
|
||||||
|
for (const item of snapshotItems) {
|
||||||
|
const model = item.model.toLowerCase()
|
||||||
|
const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model))
|
||||||
|
if (!group) continue
|
||||||
|
|
||||||
|
const existing = groupMap.get(group.key)
|
||||||
|
if (!existing) {
|
||||||
|
groupMap.set(group.key, {
|
||||||
|
label: group.label,
|
||||||
|
maxUsed: item.usedPercent,
|
||||||
|
resetSeconds: item.resetSeconds,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (item.usedPercent > existing.maxUsed) {
|
||||||
|
existing.maxUsed = item.usedPercent
|
||||||
|
}
|
||||||
|
if (existing.resetSeconds === null) {
|
||||||
|
existing.resetSeconds = item.resetSeconds
|
||||||
|
} else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) {
|
||||||
|
existing.resetSeconds = item.resetSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AntigravityQuotaSummaryItem[] = []
|
||||||
|
for (const group of ANTIGRAVITY_QUOTA_GROUPS) {
|
||||||
|
const data = groupMap.get(group.key)
|
||||||
|
if (!data) continue
|
||||||
|
result.push({
|
||||||
|
key: group.key,
|
||||||
|
label: data.label,
|
||||||
|
usedPercent: data.maxUsed,
|
||||||
|
remainingPercent: Math.max(100 - data.maxUsed, 0),
|
||||||
|
resetSeconds: data.resetSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAntigravityQuotaSummary(key.upstream_metadata)
|
||||||
|
}
|
||||||
|
|
||||||
function getResetCountdownText(
|
function getResetCountdownText(
|
||||||
resetAt: number | null | undefined,
|
resetAt: number | null | undefined,
|
||||||
resetSecs: number | null | undefined,
|
resetSecs: number | null | undefined,
|
||||||
|
|||||||
225
frontend/src/utils/providerKeyQuota.ts
Normal file
225
frontend/src/utils/providerKeyQuota.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import type {
|
||||||
|
ProviderKeyStatusSnapshot,
|
||||||
|
QuotaStatusSnapshot,
|
||||||
|
QuotaWindowSnapshot,
|
||||||
|
} from '@/api/endpoints/types/statusSnapshot'
|
||||||
|
|
||||||
|
export interface ProviderKeyQuotaCarrier {
|
||||||
|
account_quota?: string | null
|
||||||
|
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const text = value.trim()
|
||||||
|
return text || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercent(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
if (value < 0) return 0
|
||||||
|
if (value > 100) return 100
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return `${clampPercent(value).toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaSnapshot(
|
||||||
|
input: ProviderKeyQuotaCarrier,
|
||||||
|
): QuotaStatusSnapshot | null {
|
||||||
|
return input.status_snapshot?.quota ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaProviderType(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
fallbackProviderType?: string | null,
|
||||||
|
): string {
|
||||||
|
const snapshotProviderType = normalizeText(quota?.provider_type)?.toLowerCase()
|
||||||
|
if (snapshotProviderType) return snapshotProviderType
|
||||||
|
return normalizeText(fallbackProviderType)?.toLowerCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindows(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
): QuotaWindowSnapshot[] {
|
||||||
|
return Array.isArray(quota?.windows) ? quota.windows : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowRemainingPercent(
|
||||||
|
window: QuotaWindowSnapshot | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!window) return null
|
||||||
|
if (typeof window.remaining_ratio === 'number') {
|
||||||
|
return clampPercent(window.remaining_ratio * 100)
|
||||||
|
}
|
||||||
|
if (typeof window.used_ratio === 'number') {
|
||||||
|
return clampPercent((1 - window.used_ratio) * 100)
|
||||||
|
}
|
||||||
|
if (typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||||
|
if (typeof window.remaining_value === 'number') {
|
||||||
|
return clampPercent((window.remaining_value / window.limit_value) * 100)
|
||||||
|
}
|
||||||
|
if (typeof window.used_value === 'number') {
|
||||||
|
return clampPercent((1 - (window.used_value / window.limit_value)) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindow(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
code: string,
|
||||||
|
): QuotaWindowSnapshot | null {
|
||||||
|
const normalizedCode = code.trim().toLowerCase()
|
||||||
|
return getQuotaWindows(quota).find(window => normalizeText(window.code)?.toLowerCase() === normalizedCode) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowsByScope(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
scope: string,
|
||||||
|
): QuotaWindowSnapshot[] {
|
||||||
|
const normalizedScope = scope.trim().toLowerCase()
|
||||||
|
return getQuotaWindows(quota).filter(window => normalizeText(window.scope)?.toLowerCase() === normalizedScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuotaValue(value: number | null | undefined): string {
|
||||||
|
const normalized = Number(value)
|
||||||
|
if (!Number.isFinite(normalized)) return '0'
|
||||||
|
const rounded = Math.round(normalized)
|
||||||
|
if (Math.abs(normalized - rounded) < 1e-6) {
|
||||||
|
return String(rounded)
|
||||||
|
}
|
||||||
|
return normalized.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const [label, code] of [['周', 'weekly'], ['5H', '5h']] as const) {
|
||||||
|
const remainingPercent = getQuotaWindowRemainingPercent(getQuotaWindow(quota, code))
|
||||||
|
if (remainingPercent == null) continue
|
||||||
|
parts.push(`${label}剩余 ${formatPercent(remainingPercent)}`)
|
||||||
|
}
|
||||||
|
if (parts.length > 0) return parts.join(' | ')
|
||||||
|
|
||||||
|
if (quota.credits?.has_credits === true && typeof quota.credits.balance === 'number') {
|
||||||
|
return `积分 ${quota.credits.balance.toFixed(2)}`
|
||||||
|
}
|
||||||
|
if (quota.credits?.has_credits === true) return '有积分'
|
||||||
|
if (quota.credits?.has_credits === false) return '无可用积分'
|
||||||
|
|
||||||
|
return normalizeText(quota.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKiroQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||||
|
const code = normalizeText(quota.code)?.toLowerCase()
|
||||||
|
if (code === 'banned') {
|
||||||
|
return normalizeText(quota.label) || '账号已封禁'
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = getQuotaWindow(quota, 'usage') ?? getQuotaWindowsByScope(quota, 'account')[0] ?? null
|
||||||
|
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||||
|
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0 && window.remaining_value <= 0) {
|
||||||
|
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||||
|
}
|
||||||
|
if (remainingPercent != null) {
|
||||||
|
if (typeof window?.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||||
|
return `剩余 ${formatPercent(remainingPercent)} (${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)})`
|
||||||
|
}
|
||||||
|
return `剩余 ${formatPercent(remainingPercent)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||||
|
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeText(quota.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAntigravityQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||||
|
const code = normalizeText(quota.code)?.toLowerCase()
|
||||||
|
if (code === 'forbidden') {
|
||||||
|
return normalizeText(quota.label) || '访问受限'
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingList = getQuotaWindowsByScope(quota, 'model')
|
||||||
|
.map(getQuotaWindowRemainingPercent)
|
||||||
|
.filter((value): value is number => value != null)
|
||||||
|
|
||||||
|
if (remainingList.length === 0) return normalizeText(quota.label)
|
||||||
|
|
||||||
|
const minimumRemaining = Math.min(...remainingList)
|
||||||
|
if (remainingList.length === 1) {
|
||||||
|
return `剩余 ${formatPercent(minimumRemaining)}`
|
||||||
|
}
|
||||||
|
return `最低剩余 ${formatPercent(minimumRemaining)} (${remainingList.length} 模型)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGeminiCliQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||||
|
const modelWindows = getQuotaWindowsByScope(quota, 'model')
|
||||||
|
const activeCoolingModels = modelWindows
|
||||||
|
.filter((window) => {
|
||||||
|
if (window.is_exhausted === true) return true
|
||||||
|
if (typeof window.used_ratio === 'number') return window.used_ratio >= 1.0 - 1e-6
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.filter((window) => {
|
||||||
|
if (typeof window.reset_at !== 'number') return true
|
||||||
|
return window.reset_at > Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.map((window) => normalizeText(window.label) || normalizeText(window.model) || '模型')
|
||||||
|
|
||||||
|
if (activeCoolingModels.length === 1) {
|
||||||
|
return `${activeCoolingModels[0]} 冷却中`
|
||||||
|
}
|
||||||
|
if (activeCoolingModels.length > 1) {
|
||||||
|
return `${activeCoolingModels.length} 个模型冷却中`
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingList = modelWindows
|
||||||
|
.map(getQuotaWindowRemainingPercent)
|
||||||
|
.filter((value): value is number => value != null)
|
||||||
|
if (remainingList.length === 0) return normalizeText(quota.label)
|
||||||
|
|
||||||
|
const minimumRemaining = Math.min(...remainingList)
|
||||||
|
if (remainingList.length === 1) {
|
||||||
|
return `剩余 ${formatPercent(minimumRemaining)}`
|
||||||
|
}
|
||||||
|
return `最低剩余 ${formatPercent(minimumRemaining)} (${remainingList.length} 模型)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegacyAccountQuotaText(
|
||||||
|
input: ProviderKeyQuotaCarrier,
|
||||||
|
): string | null {
|
||||||
|
return normalizeText(input.account_quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuotaSnapshotFallbackText(
|
||||||
|
input: ProviderKeyQuotaCarrier,
|
||||||
|
fallbackProviderType?: string | null,
|
||||||
|
): string | null {
|
||||||
|
const quota = getQuotaSnapshot(input)
|
||||||
|
if (!quota) return null
|
||||||
|
|
||||||
|
const providerType = getQuotaProviderType(quota, fallbackProviderType)
|
||||||
|
switch (providerType) {
|
||||||
|
case 'codex':
|
||||||
|
return getCodexQuotaText(quota)
|
||||||
|
case 'kiro':
|
||||||
|
return getKiroQuotaText(quota)
|
||||||
|
case 'antigravity':
|
||||||
|
return getAntigravityQuotaText(quota)
|
||||||
|
case 'gemini_cli':
|
||||||
|
return getGeminiCliQuotaText(quota)
|
||||||
|
default:
|
||||||
|
return normalizeText(quota.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuotaDisplayText(
|
||||||
|
input: ProviderKeyQuotaCarrier,
|
||||||
|
fallbackProviderType?: string | null,
|
||||||
|
): string | null {
|
||||||
|
return getQuotaSnapshotFallbackText(input, fallbackProviderType) || getLegacyAccountQuotaText(input)
|
||||||
|
}
|
||||||
@@ -546,10 +546,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-else-if="key.account_quota"
|
v-else-if="getQuotaFallbackText(key)"
|
||||||
:class="getQuotaTextClass(key.account_quota)"
|
:class="getQuotaTextClass(getQuotaFallbackText(key) || '')"
|
||||||
>
|
>
|
||||||
{{ key.account_quota }}
|
{{ getQuotaFallbackText(key) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
@@ -833,10 +833,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="key.account_quota"
|
v-else-if="getQuotaFallbackText(key)"
|
||||||
:class="getQuotaTextClass(key.account_quota)"
|
:class="getQuotaTextClass(getQuotaFallbackText(key) || '')"
|
||||||
>
|
>
|
||||||
{{ key.account_quota }}
|
{{ getQuotaFallbackText(key) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -1180,6 +1180,7 @@ import type {
|
|||||||
PoolAdvancedConfig,
|
PoolAdvancedConfig,
|
||||||
ProviderWithEndpointsSummary,
|
ProviderWithEndpointsSummary,
|
||||||
} from '@/api/endpoints/types/provider'
|
} from '@/api/endpoints/types/provider'
|
||||||
|
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||||
import { getProvider, updateProvider } from '@/api/endpoints'
|
import { getProvider, updateProvider } from '@/api/endpoints'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
||||||
@@ -1223,6 +1224,10 @@ import {
|
|||||||
getOAuthStatusDisplay,
|
getOAuthStatusDisplay,
|
||||||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||||
} from '@/utils/providerKeyStatus'
|
} from '@/utils/providerKeyStatus'
|
||||||
|
import {
|
||||||
|
getLegacyAccountQuotaText,
|
||||||
|
getQuotaDisplayText,
|
||||||
|
} from '@/utils/providerKeyQuota'
|
||||||
|
|
||||||
const { success, error: showError, warning: showWarning } = useToast()
|
const { success, error: showError, warning: showWarning } = useToast()
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
@@ -2569,15 +2574,39 @@ function getOAuthStatusTitle(key: PoolKeyDetail): string {
|
|||||||
|
|
||||||
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
|
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
|
||||||
|
|
||||||
|
function getQuotaAlertSnapshotState(key: PoolKeyDetail): { label: string, title: string } | null {
|
||||||
|
const quota = getQuotaSnapshot(key)
|
||||||
|
if (!quota) return null
|
||||||
|
|
||||||
|
const code = String(quota.code || '').trim().toLowerCase()
|
||||||
|
if (code !== 'banned' && code !== 'forbidden') return null
|
||||||
|
|
||||||
|
let label = String(quota.label || '').trim()
|
||||||
|
if (!label) {
|
||||||
|
label = code === 'banned' ? '账号封禁' : '访问受限'
|
||||||
|
} else if (label === '账号已封禁' || label === '封禁') {
|
||||||
|
label = '账号封禁'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = String(quota.reason || '').trim()
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
title: reason ? `${label}: ${reason}` : label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getAccountAlertLabel(key: PoolKeyDetail): string | null {
|
function getAccountAlertLabel(key: PoolKeyDetail): string | null {
|
||||||
const cached = _accountAlertCache.get(key)
|
const cached = _accountAlertCache.get(key)
|
||||||
if (cached !== undefined) return cached
|
if (cached !== undefined) return cached
|
||||||
|
|
||||||
let result: string | null = getAccountStatusDisplay(key).label
|
let result: string | null = getAccountStatusDisplay(key).label
|
||||||
const quotaText = String(key.account_quota || '').trim()
|
const quotaAlert = getQuotaAlertSnapshotState(key)
|
||||||
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
|
if (!result && quotaAlert) result = quotaAlert.label
|
||||||
if (!result && (quotaText === '账号已封禁' || quotaText === '封禁')) result = '账号封禁'
|
if (!result && !getQuotaSnapshot(key)) {
|
||||||
else if (!result && quotaText === '访问受限') result = '访问受限'
|
const quotaText = getLegacyAccountQuotaText(key)
|
||||||
|
if (quotaText === '账号已封禁' || quotaText === '封禁') result = '账号封禁'
|
||||||
|
else if (quotaText === '访问受限') result = '访问受限'
|
||||||
|
}
|
||||||
|
|
||||||
_accountAlertCache.set(key, result)
|
_accountAlertCache.set(key, result)
|
||||||
return result
|
return result
|
||||||
@@ -2590,7 +2619,10 @@ function getAccountAlertTitle(key: PoolKeyDetail): string {
|
|||||||
const accountTitle = getAccountStatusTitle(key)
|
const accountTitle = getAccountStatusTitle(key)
|
||||||
if (accountTitle) return accountTitle
|
if (accountTitle) return accountTitle
|
||||||
|
|
||||||
const quotaText = String(key.account_quota || '').trim()
|
const quotaAlert = getQuotaAlertSnapshotState(key)
|
||||||
|
if (quotaAlert?.title) return quotaAlert.title
|
||||||
|
|
||||||
|
const quotaText = getLegacyAccountQuotaText(key)
|
||||||
if (quotaText) return `${label}: ${quotaText}`
|
if (quotaText) return `${label}: ${quotaText}`
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
@@ -2646,6 +2678,10 @@ function getQuotaProgressDisplayText(item: QuotaProgressItem): string {
|
|||||||
return item.detail?.trim() || ''
|
return item.detail?.trim() || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getQuotaFallbackText(key: PoolKeyDetail): string | null {
|
||||||
|
return getQuotaDisplayText(key, selectedProviderType.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function getQuotaLabelOrder(label: string): number {
|
function getQuotaLabelOrder(label: string): number {
|
||||||
@@ -2676,24 +2712,186 @@ function normalizeRemainingSeconds(raw: number | null | undefined): number | nul
|
|||||||
return Math.floor(value)
|
return Math.floor(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getQuotaSnapshot(key: PoolKeyDetail): QuotaStatusSnapshot | null {
|
||||||
|
const quota = key.status_snapshot?.quota
|
||||||
|
if (!quota) return null
|
||||||
|
return quota
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaSnapshotProviderType(key: PoolKeyDetail): string {
|
||||||
|
const snapshotProviderType = String(getQuotaSnapshot(key)?.provider_type || '').trim().toLowerCase()
|
||||||
|
if (snapshotProviderType) return snapshotProviderType
|
||||||
|
return selectedProviderType.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexQuotaSnapshot(key: PoolKeyDetail): QuotaStatusSnapshot | null {
|
||||||
|
const quota = getQuotaSnapshot(key)
|
||||||
|
if (!quota) return null
|
||||||
|
return getQuotaSnapshotProviderType(key) === 'codex' ? quota : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaSnapshotUpdatedAtSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
|
||||||
|
return normalizeUnixSeconds(quota?.updated_at ?? quota?.observed_at ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaSnapshotWindow(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
code: string,
|
||||||
|
): QuotaWindowSnapshot | null {
|
||||||
|
const windows = quota?.windows
|
||||||
|
if (!Array.isArray(windows)) return null
|
||||||
|
|
||||||
|
const normalizedCode = code.trim().toLowerCase()
|
||||||
|
return windows.find(window => String(window?.code || '').trim().toLowerCase() === normalizedCode) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaSnapshotWindowsByScope(
|
||||||
|
quota: QuotaStatusSnapshot | null | undefined,
|
||||||
|
scope: string,
|
||||||
|
): QuotaWindowSnapshot[] {
|
||||||
|
const windows = quota?.windows
|
||||||
|
if (!Array.isArray(windows)) return []
|
||||||
|
|
||||||
|
const normalizedScope = scope.trim().toLowerCase()
|
||||||
|
return windows.filter(window => String(window?.scope || '').trim().toLowerCase() === normalizedScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowUsedPercent(window: QuotaWindowSnapshot | null | undefined): number | null {
|
||||||
|
if (!window) return null
|
||||||
|
if (typeof window.used_ratio === 'number') {
|
||||||
|
return clampPercent(window.used_ratio * 100)
|
||||||
|
}
|
||||||
|
if (typeof window.remaining_ratio === 'number') {
|
||||||
|
return clampPercent((1 - window.remaining_ratio) * 100)
|
||||||
|
}
|
||||||
|
if (typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||||
|
if (typeof window.remaining_value === 'number') {
|
||||||
|
return clampPercent((1 - (window.remaining_value / window.limit_value)) * 100)
|
||||||
|
}
|
||||||
|
if (typeof window.used_value === 'number') {
|
||||||
|
return clampPercent((window.used_value / window.limit_value) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaWindowRemainingPercent(window: QuotaWindowSnapshot | null | undefined): number | null {
|
||||||
|
if (!window) return null
|
||||||
|
if (typeof window.remaining_ratio === 'number') {
|
||||||
|
return clampPercent(window.remaining_ratio * 100)
|
||||||
|
}
|
||||||
|
const usedPercent = getQuotaWindowUsedPercent(window)
|
||||||
|
return usedPercent == null ? null : clampPercent(100 - usedPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuotaValue(value: number | null | undefined): string {
|
||||||
|
const normalized = Number(value)
|
||||||
|
if (!Number.isFinite(normalized)) return '0'
|
||||||
|
const rounded = Math.round(normalized)
|
||||||
|
if (Math.abs(normalized - rounded) < 1e-6) {
|
||||||
|
return String(rounded)
|
||||||
|
}
|
||||||
|
return normalized.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressItem[] {
|
||||||
|
const quota = getQuotaSnapshot(key)
|
||||||
|
if (!quota) return []
|
||||||
|
|
||||||
|
const providerType = getQuotaSnapshotProviderType(key)
|
||||||
|
|
||||||
|
if (providerType === 'codex') {
|
||||||
|
const items: QuotaProgressItem[] = []
|
||||||
|
for (const [label, code] of [['5H', '5h'], ['周', 'weekly']] as const) {
|
||||||
|
const window = getQuotaSnapshotWindow(quota, code)
|
||||||
|
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||||
|
if (remainingPercent == null) continue
|
||||||
|
items.push({
|
||||||
|
label,
|
||||||
|
remainingPercent,
|
||||||
|
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||||
|
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||||
|
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerType === 'kiro') {
|
||||||
|
const window = getQuotaSnapshotWindow(quota, 'usage')
|
||||||
|
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
|
||||||
|
?? null
|
||||||
|
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||||
|
if (remainingPercent == null) return []
|
||||||
|
|
||||||
|
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
|
||||||
|
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return [{
|
||||||
|
label: '剩余',
|
||||||
|
remainingPercent,
|
||||||
|
detail,
|
||||||
|
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||||
|
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||||
|
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerType === 'antigravity') {
|
||||||
|
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||||
|
if (windows.length === 0) return []
|
||||||
|
|
||||||
|
const remainingPercents = windows
|
||||||
|
.map(getQuotaWindowRemainingPercent)
|
||||||
|
.filter((value): value is number => value != null)
|
||||||
|
if (remainingPercents.length === 0) return []
|
||||||
|
|
||||||
|
return [{
|
||||||
|
label: '最低',
|
||||||
|
remainingPercent: Math.min(...remainingPercents),
|
||||||
|
detail: `${windows.length} 模型`,
|
||||||
|
resetAtSeconds: null,
|
||||||
|
resetSeconds: null,
|
||||||
|
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerType === 'gemini_cli') {
|
||||||
|
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||||
|
if (windows.length === 0) return []
|
||||||
|
|
||||||
|
const remainingPercents = windows
|
||||||
|
.map(getQuotaWindowRemainingPercent)
|
||||||
|
.filter((value): value is number => value != null)
|
||||||
|
if (remainingPercents.length === 0) return []
|
||||||
|
|
||||||
|
return [{
|
||||||
|
label: '最低',
|
||||||
|
remainingPercent: Math.min(...remainingPercents),
|
||||||
|
detail: `${windows.length} 模型`,
|
||||||
|
resetAtSeconds: null,
|
||||||
|
resetSeconds: null,
|
||||||
|
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
function resolveCodexQuotaCountdown(
|
function resolveCodexQuotaCountdown(
|
||||||
key: PoolKeyDetail,
|
key: PoolKeyDetail,
|
||||||
label: string
|
label: string
|
||||||
): Pick<QuotaProgressItem, 'resetAtSeconds' | 'resetSeconds' | 'updatedAtSeconds'> | null {
|
): Pick<QuotaProgressItem, 'resetAtSeconds' | 'resetSeconds' | 'updatedAtSeconds'> | null {
|
||||||
if (label !== '5H' && label !== '周') return null
|
if (label !== '5H' && label !== '周') return null
|
||||||
const codex = key.upstream_metadata?.codex
|
|
||||||
if (!codex) return null
|
|
||||||
|
|
||||||
const isWeeklyWindow = label === '周'
|
const codexSnapshot = getCodexQuotaSnapshot(key)
|
||||||
const resetAtSeconds = normalizeUnixSeconds(
|
const snapshotWindow = getQuotaSnapshotWindow(codexSnapshot, label === '周' ? 'weekly' : '5h')
|
||||||
isWeeklyWindow ? codex.primary_reset_at : codex.secondary_reset_at
|
if (!snapshotWindow) return null
|
||||||
)
|
|
||||||
const resetSeconds = normalizeRemainingSeconds(
|
const resetAtSeconds = normalizeUnixSeconds(snapshotWindow.reset_at ?? null)
|
||||||
isWeeklyWindow
|
const resetSeconds = normalizeRemainingSeconds(snapshotWindow.reset_seconds ?? null)
|
||||||
? (codex.primary_reset_seconds ?? codex.primary_reset_after_seconds ?? null)
|
const updatedAtSeconds = getQuotaSnapshotUpdatedAtSeconds(codexSnapshot)
|
||||||
: (codex.secondary_reset_seconds ?? codex.secondary_reset_after_seconds ?? null)
|
|
||||||
)
|
|
||||||
const updatedAtSeconds = normalizeUnixSeconds(codex.updated_at)
|
|
||||||
|
|
||||||
if (resetAtSeconds == null && resetSeconds == null) return null
|
if (resetAtSeconds == null && resetSeconds == null) return null
|
||||||
return { resetAtSeconds, resetSeconds, updatedAtSeconds }
|
return { resetAtSeconds, resetSeconds, updatedAtSeconds }
|
||||||
@@ -2723,7 +2921,18 @@ function parseQuotaResetRemainingSeconds(detail: string | undefined): number | n
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseQuotaProgressItems(key: PoolKeyDetail): QuotaProgressItem[] {
|
function parseQuotaProgressItems(key: PoolKeyDetail): QuotaProgressItem[] {
|
||||||
const quotaText = key.account_quota
|
const snapshotItems = buildQuotaProgressItemsFromSnapshot(key)
|
||||||
|
if (snapshotItems.length > 0) {
|
||||||
|
return snapshotItems.sort((a, b) => {
|
||||||
|
const orderDiff = getQuotaLabelOrder(a.label) - getQuotaLabelOrder(b.label)
|
||||||
|
if (orderDiff !== 0) return orderDiff
|
||||||
|
return a.label.localeCompare(b.label, 'zh-Hans-CN')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getQuotaSnapshot(key)) return []
|
||||||
|
|
||||||
|
const quotaText = getLegacyAccountQuotaText(key)
|
||||||
if (!quotaText) return []
|
if (!quotaText) return []
|
||||||
|
|
||||||
const segments = quotaText
|
const segments = quotaText
|
||||||
|
|||||||
Reference in New Issue
Block a user