mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
Merge pull request #768 from zhefox/main
fix(pool): isolate model quotas and compact account display
This commit is contained in:
@@ -105,6 +105,7 @@ async fn schedule_pool_page_candidates(
|
||||
candidates: Vec<EligibleLocalExecutionCandidate>,
|
||||
sticky_session_token: Option<&str>,
|
||||
effective_pool_config: Option<&AdminProviderPoolConfig>,
|
||||
provider_model_name: Option<&str>,
|
||||
) -> (
|
||||
Vec<EligibleLocalExecutionCandidate>,
|
||||
Vec<SkippedLocalExecutionCandidate>,
|
||||
@@ -128,7 +129,8 @@ async fn schedule_pool_page_candidates(
|
||||
entry.1.insert(candidate.candidate.key_id.clone());
|
||||
}
|
||||
|
||||
let key_context_by_id = read_pool_catalog_key_contexts_by_id(state, &candidates).await;
|
||||
let key_context_by_id =
|
||||
read_pool_catalog_key_contexts_by_id(state, &candidates, provider_model_name).await;
|
||||
|
||||
let mut runtime_by_provider = BTreeMap::new();
|
||||
let mut pool_config_by_provider = BTreeMap::new();
|
||||
@@ -1034,6 +1036,7 @@ impl<'a> PoolKeyCursor<'a> {
|
||||
candidates,
|
||||
self.sticky_session_token.as_deref(),
|
||||
self.effective_pool_config.as_ref(),
|
||||
Some(self.group.candidate.selected_provider_model_name.as_str()),
|
||||
)
|
||||
.await;
|
||||
self.record_skipped_candidates(&skipped);
|
||||
@@ -1406,6 +1409,7 @@ fn pool_candidate_from_catalog_key(
|
||||
async fn read_pool_catalog_key_contexts_by_id(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: &[EligibleLocalExecutionCandidate],
|
||||
provider_model_name: Option<&str>,
|
||||
) -> BTreeMap<String, PoolCatalogKeyContext> {
|
||||
let mut key_ids = Vec::new();
|
||||
let mut provider_type_by_key_id = BTreeMap::<String, String>::new();
|
||||
@@ -1451,7 +1455,13 @@ async fn read_pool_catalog_key_contexts_by_id(
|
||||
.unwrap_or_default();
|
||||
(
|
||||
key.id.clone(),
|
||||
build_pool_catalog_key_context(state, &provider_pool_service, &key, provider_type),
|
||||
build_pool_catalog_key_context(
|
||||
state,
|
||||
&provider_pool_service,
|
||||
&key,
|
||||
provider_type,
|
||||
provider_model_name,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -1462,6 +1472,7 @@ fn build_pool_catalog_key_context(
|
||||
provider_pool_service: &ProviderPoolService,
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
provider_model_name: Option<&str>,
|
||||
) -> PoolCatalogKeyContext {
|
||||
let (health_score, _, _, _, _) = provider_key_health_summary(key);
|
||||
let health_score = key
|
||||
@@ -1480,8 +1491,12 @@ fn build_pool_catalog_key_context(
|
||||
.filter(|value| value.is_finite() && *value >= 0.0);
|
||||
|
||||
let auth_config = parse_catalog_auth_config_json(state.app(), key);
|
||||
let mut signals =
|
||||
provider_pool_service.member_signals(provider_type, key, auth_config.as_ref());
|
||||
let mut signals = provider_pool_service.member_signals(
|
||||
provider_type,
|
||||
key,
|
||||
auth_config.as_ref(),
|
||||
provider_model_name,
|
||||
);
|
||||
signals.account_blocked |= admin_provider_pool_pure::admin_pool_key_is_known_banned(key);
|
||||
signals.account_blocked |=
|
||||
pool_key_requires_reauth_for_scheduling(key, current_unix_ms().saturating_div(1000));
|
||||
@@ -4465,6 +4480,7 @@ mod tests {
|
||||
&ProviderPoolService::with_builtin_adapters(),
|
||||
&key,
|
||||
"codex",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(context.plan_tier.as_deref(), Some("team"));
|
||||
@@ -4510,6 +4526,7 @@ mod tests {
|
||||
&ProviderPoolService::with_builtin_adapters(),
|
||||
&key,
|
||||
"codex",
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context.quota_exhausted);
|
||||
@@ -4530,6 +4547,7 @@ mod tests {
|
||||
&ProviderPoolService::with_builtin_adapters(),
|
||||
&key,
|
||||
"codex",
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context.quota_exhausted);
|
||||
@@ -4560,11 +4578,59 @@ mod tests {
|
||||
&ProviderPoolService::with_builtin_adapters(),
|
||||
&key,
|
||||
"antigravity",
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_catalog_context_scopes_antigravity_exhaustion_to_requested_model() {
|
||||
let mut key = sample_catalog_oauth_key("key-antigravity-model-quota");
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"version": 2,
|
||||
"provider_type": "antigravity",
|
||||
"exhausted": false,
|
||||
"windows": [
|
||||
{
|
||||
"code": "model:gemini-3.1-pro-high",
|
||||
"scope": "model",
|
||||
"model": "gemini-3.1-pro-high",
|
||||
"used_ratio": 1.0,
|
||||
"is_exhausted": true
|
||||
},
|
||||
{
|
||||
"code": "model:gemini-3-flash-agent",
|
||||
"scope": "model",
|
||||
"model": "gemini-3-flash-agent",
|
||||
"used_ratio": 0.1,
|
||||
"is_exhausted": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
let app = app_state_with_catalog_key(key.clone());
|
||||
let exhausted = build_pool_catalog_key_context(
|
||||
PlannerAppState::new(&app),
|
||||
&ProviderPoolService::with_builtin_adapters(),
|
||||
&key,
|
||||
"antigravity",
|
||||
Some("gemini-3.1-pro-high"),
|
||||
);
|
||||
let available = build_pool_catalog_key_context(
|
||||
PlannerAppState::new(&app),
|
||||
&ProviderPoolService::with_builtin_adapters(),
|
||||
&key,
|
||||
"antigravity",
|
||||
Some("gemini-3-flash-agent"),
|
||||
);
|
||||
|
||||
assert!(exhausted.quota_exhausted);
|
||||
assert!(!available.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_catalog_context_marks_known_banned_account_from_metadata() {
|
||||
let mut key = sample_catalog_oauth_key("key-account-banned");
|
||||
@@ -4581,6 +4647,7 @@ mod tests {
|
||||
&ProviderPoolService::with_builtin_adapters(),
|
||||
&key,
|
||||
"codex",
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context.account_blocked);
|
||||
|
||||
@@ -561,7 +561,7 @@ mod tests {
|
||||
}
|
||||
})));
|
||||
|
||||
let signals = service.member_signals("windsurf", &key, None);
|
||||
let signals = service.member_signals("windsurf", &key, None, None);
|
||||
|
||||
assert!(!signals.quota_exhausted);
|
||||
}
|
||||
@@ -588,7 +588,7 @@ mod tests {
|
||||
}
|
||||
}));
|
||||
|
||||
let signals = service.member_signals("windsurf", &key, None);
|
||||
let signals = service.member_signals("windsurf", &key, None, None);
|
||||
|
||||
assert!(signals.quota_exhausted);
|
||||
}
|
||||
@@ -845,6 +845,99 @@ mod tests {
|
||||
assert!(provider_pool_key_account_quota_exhausted(&active, "codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn antigravity_model_quota_exhaustion_does_not_block_other_models() {
|
||||
let service = ProviderPoolService::with_builtin_adapters();
|
||||
let mut key = sample_key(None);
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"version": 2,
|
||||
"provider_type": "antigravity",
|
||||
"exhausted": false,
|
||||
"windows": [
|
||||
{
|
||||
"code": "model:gemini-3.1-pro-high",
|
||||
"scope": "model",
|
||||
"model": "gemini-3.1-pro-high",
|
||||
"used_ratio": 1.0,
|
||||
"is_exhausted": true
|
||||
},
|
||||
{
|
||||
"code": "model:gemini-3-flash-agent",
|
||||
"scope": "model",
|
||||
"model": "gemini-3-flash-agent",
|
||||
"used_ratio": 0.1,
|
||||
"is_exhausted": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
let exhausted =
|
||||
service.member_signals("antigravity", &key, None, Some("gemini-3.1-pro-high"));
|
||||
let available =
|
||||
service.member_signals("antigravity", &key, None, Some("gemini-3-flash-agent"));
|
||||
|
||||
assert!(exhausted.quota_exhausted);
|
||||
assert!(!available.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_standard_and_spark_quota_families_are_independent() {
|
||||
let service = ProviderPoolService::with_builtin_adapters();
|
||||
let mut standard_exhausted = sample_key(None);
|
||||
standard_exhausted.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"version": 2,
|
||||
"provider_type": "codex",
|
||||
"exhausted": true,
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"windows": [
|
||||
{ "code": "weekly", "used_ratio": 1.0, "is_exhausted": true },
|
||||
{ "code": "5h", "used_ratio": 0.5, "is_exhausted": false },
|
||||
{ "code": "spark_weekly", "used_ratio": 0.2, "is_exhausted": false },
|
||||
{ "code": "spark_5h", "used_ratio": 0.1, "is_exhausted": false }
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
let standard =
|
||||
service.member_signals("codex", &standard_exhausted, None, Some("gpt-5.3-codex"));
|
||||
let spark = service.member_signals(
|
||||
"codex",
|
||||
&standard_exhausted,
|
||||
None,
|
||||
Some("gpt-5.3-codex-spark"),
|
||||
);
|
||||
assert!(standard.quota_exhausted);
|
||||
assert!(!standard.quota_hard_blocked);
|
||||
assert!(!spark.quota_exhausted);
|
||||
assert!(!spark.quota_hard_blocked);
|
||||
|
||||
let mut spark_exhausted = sample_key(None);
|
||||
spark_exhausted.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"version": 2,
|
||||
"provider_type": "codex",
|
||||
"exhausted": false,
|
||||
"windows": [
|
||||
{ "code": "weekly", "used_ratio": 0.2, "is_exhausted": false },
|
||||
{ "code": "5h", "used_ratio": 0.1, "is_exhausted": false },
|
||||
{ "code": "spark_weekly", "used_ratio": 1.0, "is_exhausted": true },
|
||||
{ "code": "spark_5h", "used_ratio": 0.4, "is_exhausted": false }
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
let standard =
|
||||
service.member_signals("codex", &spark_exhausted, None, Some("gpt-5.3-codex"));
|
||||
let spark =
|
||||
service.member_signals("codex", &spark_exhausted, None, Some("gpt-5.3-codex-spark"));
|
||||
assert!(!standard.quota_exhausted);
|
||||
assert!(spark.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_quota_exhaustion_metadata_expires_after_reset_at() {
|
||||
let now = std::time::SystemTime::now()
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct ProviderPoolMemberInput<'a> {
|
||||
pub provider_type: &'a str,
|
||||
pub key: &'a StoredProviderCatalogKey,
|
||||
pub auth_config: Option<&'a Map<String, Value>>,
|
||||
pub provider_model_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub trait ProviderPoolAdapter: Send + Sync {
|
||||
|
||||
@@ -6,7 +6,9 @@ use serde_json::json;
|
||||
use crate::capability::ProviderPoolCapabilities;
|
||||
use crate::provider::{
|
||||
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
|
||||
ProviderPoolMemberInput,
|
||||
};
|
||||
use crate::quota::provider_pool_model_quota_exhausted;
|
||||
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
|
||||
|
||||
pub const ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH: &str = "/v1internal:fetchAvailableModels";
|
||||
@@ -26,6 +28,21 @@ impl ProviderPoolAdapter for AntigravityProviderPoolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
|
||||
input
|
||||
.provider_model_name
|
||||
.and_then(|model| {
|
||||
provider_pool_model_quota_exhausted(input.key, input.provider_type, model)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
crate::quota::provider_pool_quota_snapshot_exhausted_decision(
|
||||
input.key,
|
||||
input.provider_type,
|
||||
)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_refresh_endpoint(
|
||||
&self,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::provider::{
|
||||
use crate::quota::{
|
||||
provider_pool_current_unix_secs, provider_pool_json_bool, provider_pool_json_f64,
|
||||
provider_pool_member_quota_snapshot, provider_pool_metadata_bucket,
|
||||
provider_pool_quota_snapshot_exhausted_decision, provider_pool_reset_deadline_elapsed,
|
||||
provider_pool_timestamp_unix_secs,
|
||||
provider_pool_model_quota_exhausted, provider_pool_quota_snapshot_exhausted_decision,
|
||||
provider_pool_reset_deadline_elapsed, provider_pool_timestamp_unix_secs,
|
||||
};
|
||||
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
|
||||
|
||||
@@ -49,6 +49,11 @@ impl ProviderPoolAdapter for CodexProviderPoolAdapter {
|
||||
}
|
||||
|
||||
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
|
||||
if let Some(exhausted) = input.provider_model_name.and_then(|model| {
|
||||
provider_pool_model_quota_exhausted(input.key, input.provider_type, model)
|
||||
}) {
|
||||
return exhausted;
|
||||
}
|
||||
if let Some(quota_snapshot) =
|
||||
provider_pool_member_quota_snapshot(input.key, input.provider_type)
|
||||
{
|
||||
@@ -76,6 +81,11 @@ impl ProviderPoolAdapter for CodexProviderPoolAdapter {
|
||||
}
|
||||
|
||||
fn quota_hard_blocked(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
|
||||
if input.provider_model_name.is_some_and(|model| {
|
||||
provider_pool_model_quota_exhausted(input.key, input.provider_type, model).is_some()
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
codex_explicit_quota_block_active(input.key, input.provider_type)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ pub fn provider_pool_key_account_quota_exhausted(
|
||||
provider_type,
|
||||
key,
|
||||
auth_config: None,
|
||||
provider_model_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,9 +28,60 @@ pub fn provider_pool_key_quota_hard_blocked(
|
||||
provider_type,
|
||||
key,
|
||||
auth_config: None,
|
||||
provider_model_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn provider_pool_model_quota_exhausted(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
provider_model_name: &str,
|
||||
) -> Option<bool> {
|
||||
let quota_snapshot = provider_pool_member_quota_snapshot(key, provider_type)?;
|
||||
let windows = quota_snapshot.get("windows")?.as_array()?;
|
||||
let normalized_provider = provider_type.trim().to_ascii_lowercase();
|
||||
let normalized_model = provider_model_name.trim().to_ascii_lowercase();
|
||||
|
||||
let matches_window = |window: &Map<String, Value>| {
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if normalized_provider == "codex" {
|
||||
let spark_model = normalized_model.contains("spark");
|
||||
return code.starts_with("spark_") == spark_model;
|
||||
}
|
||||
if normalized_provider == "antigravity" {
|
||||
return window
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|model| model.trim().eq_ignore_ascii_case(&normalized_model));
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
let matching_windows = windows
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.filter(|window| matches_window(window))
|
||||
.collect::<Vec<_>>();
|
||||
if matching_windows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let now_unix_secs = provider_pool_current_unix_secs();
|
||||
let snapshot_observed_at = provider_pool_timestamp_unix_secs(quota_snapshot.get("observed_at"))
|
||||
.or_else(|| provider_pool_timestamp_unix_secs(quota_snapshot.get("updated_at")));
|
||||
Some(matching_windows.iter().any(|window| {
|
||||
provider_pool_quota_window_is_exhausted(window)
|
||||
&& !now_unix_secs.is_some_and(|now| {
|
||||
provider_pool_reset_deadline_elapsed(window, snapshot_observed_at, now)
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn provider_pool_member_quota_snapshot<'a>(
|
||||
key: &'a StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
|
||||
@@ -123,12 +123,14 @@ impl ProviderPoolService {
|
||||
provider_type: &str,
|
||||
key: &StoredProviderCatalogKey,
|
||||
auth_config: Option<&Map<String, Value>>,
|
||||
provider_model_name: Option<&str>,
|
||||
) -> aether_pool_core::PoolMemberSignals {
|
||||
let adapter = self.adapter(provider_type);
|
||||
let input = ProviderPoolMemberInput {
|
||||
provider_type,
|
||||
key,
|
||||
auth_config,
|
||||
provider_model_name,
|
||||
};
|
||||
adapter.member_signals(&input)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="space-y-2"
|
||||
:class="hasNumericOnlyItems ? '' : 'space-y-2'"
|
||||
>
|
||||
<QuotaProgressRows
|
||||
:items="items"
|
||||
@@ -39,7 +39,8 @@
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="max-w-[208px] space-y-2"
|
||||
class="w-full max-w-[208px]"
|
||||
:class="hasNumericOnlyItems ? '' : 'space-y-2'"
|
||||
>
|
||||
<QuotaProgressRows :items="items" />
|
||||
<div
|
||||
@@ -64,7 +65,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
import { computed, defineComponent, h, type PropType } from 'vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export interface PoolQuotaProgressDisplayItem {
|
||||
@@ -103,6 +104,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
const hasNumericOnlyItems = computed(() => props.items.length > 0 && props.items.every(item => item.numericOnly))
|
||||
|
||||
const ResetCredits = defineComponent({
|
||||
name: 'PoolQuotaResetCredits',
|
||||
@@ -142,18 +144,28 @@ const QuotaProgressRows = defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => props.items.map((item, idx) => h('div', {
|
||||
return () => h('div', {
|
||||
'data-testid': 'pool-quota-rows',
|
||||
class: props.items.every(item => item.numericOnly)
|
||||
? 'grid grid-cols-2 gap-x-3 gap-y-1.5 min-w-0'
|
||||
: 'space-y-2',
|
||||
}, props.items.map((item, idx) => h('div', {
|
||||
key: `${item.label}-${idx}`,
|
||||
class: props.mobile
|
||||
? 'flex flex-col gap-1 min-w-0'
|
||||
: 'flex flex-col gap-1 min-w-[140px] max-w-[208px]',
|
||||
class: item.numericOnly
|
||||
? 'flex min-w-0 items-baseline justify-between gap-2 text-[10px] leading-4'
|
||||
: props.mobile
|
||||
? 'flex flex-col gap-1 min-w-0'
|
||||
: 'flex flex-col gap-1 min-w-[140px] max-w-[208px]',
|
||||
}, [
|
||||
h('div', { class: 'flex items-center justify-between text-[10px] leading-none' }, [
|
||||
h('div', { class: item.numericOnly ? 'contents' : 'flex items-center justify-between text-[10px] leading-none' }, [
|
||||
h('span', {
|
||||
'data-testid': 'pool-quota-period-label',
|
||||
class: 'text-muted-foreground font-medium shrink-0',
|
||||
class: item.numericOnly
|
||||
? 'min-w-0 truncate text-muted-foreground'
|
||||
: 'text-muted-foreground font-medium shrink-0',
|
||||
title: item.numericOnly ? item.label : undefined,
|
||||
}, item.label),
|
||||
item.resetText
|
||||
item.resetText && !item.numericOnly
|
||||
? h('span', {
|
||||
'data-testid': 'pool-quota-reset-text',
|
||||
class: 'text-muted-foreground/80 tabular-nums truncate',
|
||||
@@ -161,7 +173,7 @@ const QuotaProgressRows = defineComponent({
|
||||
}, item.resetText)
|
||||
: null,
|
||||
]),
|
||||
h('div', { class: 'flex items-center gap-1.5' }, [
|
||||
h('div', { class: item.numericOnly ? 'contents' : 'flex items-center gap-1.5' }, [
|
||||
item.numericOnly
|
||||
? null
|
||||
: h('div', {
|
||||
@@ -177,12 +189,12 @@ const QuotaProgressRows = defineComponent({
|
||||
'data-testid': 'pool-quota-meter-text',
|
||||
class: [
|
||||
'shrink-0 text-[10px] font-medium tabular-nums leading-none',
|
||||
item.numericOnly ? 'ml-auto' : '',
|
||||
item.numericOnly ? 'text-right' : '',
|
||||
item.meterClass,
|
||||
],
|
||||
}, item.meterText),
|
||||
]),
|
||||
]))
|
||||
])))
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -90,24 +90,41 @@ describe('pool key display panels', () => {
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('renders Antigravity quota as numeric values without progress tracks', () => {
|
||||
it('renders Antigravity quota summaries in a compact numeric grid', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(PoolKeyQuotaPanel, {
|
||||
items: [{
|
||||
label: 'Gemini 3.1 Pro (High)',
|
||||
remainingPercent: 42,
|
||||
resetText: '1h 后重置',
|
||||
meterText: '42',
|
||||
barClass: 'bg-amber-500',
|
||||
meterClass: 'text-amber-600',
|
||||
numericOnly: true,
|
||||
}],
|
||||
items: [
|
||||
{
|
||||
label: 'Gemini额度',
|
||||
remainingPercent: 90.6,
|
||||
resetText: '1h 后重置',
|
||||
meterText: '90.6–100',
|
||||
barClass: 'bg-emerald-500',
|
||||
meterClass: 'text-emerald-600',
|
||||
numericOnly: true,
|
||||
},
|
||||
{
|
||||
label: 'Claude额度',
|
||||
remainingPercent: 100,
|
||||
resetText: '1h 后重置',
|
||||
meterText: '100',
|
||||
barClass: 'bg-emerald-500',
|
||||
meterClass: 'text-emerald-600',
|
||||
numericOnly: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent).toBe('42')
|
||||
expect(root.querySelector('[data-testid="pool-quota-rows"]')?.className).toContain('grid-cols-2')
|
||||
expect(Array.from(root.querySelectorAll('[data-testid="pool-quota-period-label"]')).map(node => node.textContent)).toEqual([
|
||||
'Gemini额度',
|
||||
'Claude额度',
|
||||
])
|
||||
expect(Array.from(root.querySelectorAll('[data-testid="pool-quota-meter-text"]')).map(node => node.textContent)).toEqual(['90.6–100', '100'])
|
||||
expect(root.textContent).not.toContain('1h 后重置')
|
||||
expect(root.querySelector('[data-testid="pool-quota-progress-track"]')).toBeNull()
|
||||
|
||||
app.unmount()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { summarizeAntigravityQuotaItems } from '@/features/providers/utils/antigravityQuota'
|
||||
|
||||
describe('summarizeAntigravityQuotaItems', () => {
|
||||
it('groups model families without collapsing their independent quota values', () => {
|
||||
const items = summarizeAntigravityQuotaItems([
|
||||
{ model: 'claude-opus-4-6-thinking', label: 'Claude Opus', remainingPercent: 100, resetSeconds: 60 },
|
||||
{ model: 'claude-sonnet-4-6', label: 'Claude Sonnet', remainingPercent: 82, resetSeconds: 120 },
|
||||
{ model: 'gemini-3.1-pro-high', label: 'Gemini Pro', remainingPercent: 90.6, resetSeconds: 180 },
|
||||
{ model: 'gemini-3-flash-agent', label: 'Gemini Flash', remainingPercent: 95, resetSeconds: 240 },
|
||||
{ model: 'gpt-oss-120b-medium', label: 'GPT-OSS', remainingPercent: 100, resetSeconds: 300 },
|
||||
{ model: 'tab_flash_lite_preview', label: 'Tab', remainingPercent: 76, resetSeconds: 360 },
|
||||
{ model: 'chat_20706', label: 'Chat', remainingPercent: 64, resetSeconds: 420 },
|
||||
])
|
||||
|
||||
expect(items.map(item => [item.label, item.remainingPercent, item.detail])).toEqual([
|
||||
['Gemini额度', 90.6, '90.6–95'],
|
||||
['Claude额度', 82, '82–100'],
|
||||
])
|
||||
expect(items[1]?.model).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,17 @@ export interface AntigravityQuotaSortableItem {
|
||||
label: string
|
||||
remainingPercent: number
|
||||
resetSeconds: number | null
|
||||
detail?: string
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_QUOTA_GROUPS = [
|
||||
{ label: 'Gemini额度', matches: (model: string) => model.startsWith('gemini-') },
|
||||
{
|
||||
label: 'Claude额度',
|
||||
matches: (model: string) => model.startsWith('claude-') || model.startsWith('gpt-'),
|
||||
},
|
||||
] as const
|
||||
|
||||
const ANTIGRAVITY_MODEL_LABELS: Record<string, string> = {
|
||||
'gemini-pro-agent': 'Gemini 3.1 Pro (High)',
|
||||
'gemini-3.1-pro-high': 'Gemini 3.1 Pro (High)',
|
||||
@@ -115,3 +124,39 @@ export function dedupeAntigravityQuotaItemsByLabel<T extends AntigravityQuotaSor
|
||||
}
|
||||
return Array.from(selectedByLabel.values()).sort(compareAntigravityQuotaItems)
|
||||
}
|
||||
|
||||
export function summarizeAntigravityQuotaItems<T extends AntigravityQuotaSortableItem>(
|
||||
items: T[],
|
||||
): T[] {
|
||||
const itemsByGroup = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const normalizedModel = item.model.trim().toLowerCase().replace(/^model:/, '')
|
||||
const group = ANTIGRAVITY_QUOTA_GROUPS.find(candidate => candidate.matches(normalizedModel))
|
||||
if (!group) continue
|
||||
const groupedItems = itemsByGroup.get(group.label) ?? []
|
||||
groupedItems.push(item)
|
||||
itemsByGroup.set(group.label, groupedItems)
|
||||
}
|
||||
|
||||
return ANTIGRAVITY_QUOTA_GROUPS.map(group => group.label)
|
||||
.map((label) => {
|
||||
const groupedItems = itemsByGroup.get(label)
|
||||
if (!groupedItems?.length) return undefined
|
||||
const remainingValues = groupedItems
|
||||
.map(item => item.remainingPercent)
|
||||
.sort((left, right) => left - right)
|
||||
const minRemaining = remainingValues[0] ?? 0
|
||||
const maxRemaining = remainingValues.at(-1) ?? minRemaining
|
||||
const selected = groupedItems.find(item => item.remainingPercent === minRemaining) ?? groupedItems[0]
|
||||
const detail = Math.abs(maxRemaining - minRemaining) < 1e-6
|
||||
? formatAntigravityQuotaValue(minRemaining)
|
||||
: `${formatAntigravityQuotaValue(minRemaining)}–${formatAntigravityQuotaValue(maxRemaining)}`
|
||||
return { ...selected, label, remainingPercent: minRemaining, detail }
|
||||
})
|
||||
.filter((item): item is T => item !== undefined)
|
||||
}
|
||||
|
||||
function formatAntigravityQuotaValue(value: number): string {
|
||||
const rounded = Math.round(value)
|
||||
return Math.abs(value - rounded) < 1e-6 ? String(rounded) : value.toFixed(1)
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
:class="getPoolKeyRowClass(key.key_id)"
|
||||
>
|
||||
<TableCell
|
||||
class="px-4 py-3"
|
||||
class="px-4 py-3 align-top"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -324,7 +324,7 @@
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="showAccountQuotaColumn"
|
||||
class="py-3 align-middle"
|
||||
class="py-3 align-top"
|
||||
>
|
||||
<PoolKeyQuotaPanel
|
||||
:items="quotaProgressDisplayMap[key.key_id] || []"
|
||||
@@ -338,24 +338,24 @@
|
||||
@consume-reset-credit="handleConsumeCodexResetCredit(key)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 px-2 align-middle">
|
||||
<TableCell class="py-3 px-2 align-top">
|
||||
<PoolKeyStatsPanel
|
||||
:cycle="isPoolKeyCycleStatsDisplay(key)"
|
||||
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
|
||||
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<TableCell class="py-3 text-center align-top">
|
||||
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<TableCell class="py-3 text-center align-top">
|
||||
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center align-middle">
|
||||
<TableCell class="py-3 text-center align-top">
|
||||
<div class="inline-flex items-center justify-center gap-1">
|
||||
<span class="font-mono text-xs tabular-nums text-foreground/90">
|
||||
{{ formatPoolScore(key.pool_score?.score) }}
|
||||
@@ -416,7 +416,7 @@
|
||||
</Popover>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<TableCell class="py-3 text-center align-top">
|
||||
<Badge
|
||||
:variant="keyUiStateMap[key.key_id]?.schedulingBadgeVariant || 'default'"
|
||||
class="text-[10px]"
|
||||
@@ -425,7 +425,7 @@
|
||||
{{ keyUiStateMap[key.key_id]?.schedulingBadgeLabel }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 px-2 align-middle">
|
||||
<TableCell class="py-3 px-2 align-top">
|
||||
<div class="flex justify-center gap-0.5">
|
||||
<Button
|
||||
v-if="key.cooldown_reason"
|
||||
@@ -1146,6 +1146,7 @@ import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefre
|
||||
import {
|
||||
dedupeAntigravityQuotaItemsByLabel,
|
||||
resolveAntigravityQuotaLabel,
|
||||
summarizeAntigravityQuotaItems,
|
||||
} from '@/features/providers/utils/antigravityQuota'
|
||||
import {
|
||||
clearPendingCodexResetCreditIdempotencyKey,
|
||||
@@ -2064,7 +2065,7 @@ const quotaProgressDisplayMap = computed<Record<string, QuotaProgressDisplayItem
|
||||
remainingPercent: item.remainingPercent,
|
||||
resetText: getQuotaProgressResetDisplayText(item),
|
||||
meterText: item.numericOnly
|
||||
? formatQuotaValue(item.remainingPercent)
|
||||
? item.detail || formatQuotaValue(item.remainingPercent)
|
||||
: getQuotaProgressMeterDisplayText(item),
|
||||
barClass: getQuotaRemainingBarColorByRemaining(item.remainingPercent),
|
||||
meterClass: getQuotaRemainingClassByRemaining(item.remainingPercent),
|
||||
@@ -3866,7 +3867,7 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||
if (windows.length === 0) return []
|
||||
const opaqueDisplayIndex = { value: 1 }
|
||||
return dedupeAntigravityQuotaItemsByLabel(windows
|
||||
return summarizeAntigravityQuotaItems(dedupeAntigravityQuotaItemsByLabel(windows
|
||||
.map((window): (QuotaProgressItem & { model: string, resetSeconds: number | null }) | null => {
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) return null
|
||||
@@ -3882,7 +3883,7 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
allowDynamicReset: true,
|
||||
}
|
||||
})
|
||||
.filter((item): item is QuotaProgressItem & { model: string, resetSeconds: number | null } => item != null))
|
||||
.filter((item): item is QuotaProgressItem & { model: string, resetSeconds: number | null } => item != null)))
|
||||
}
|
||||
|
||||
if (providerType === 'gemini_cli') {
|
||||
|
||||
Reference in New Issue
Block a user