diff --git a/apps/aether-gateway/src/dispatch/pool_scheduler.rs b/apps/aether-gateway/src/dispatch/pool_scheduler.rs index d79af387b..eff085bd1 100644 --- a/apps/aether-gateway/src/dispatch/pool_scheduler.rs +++ b/apps/aether-gateway/src/dispatch/pool_scheduler.rs @@ -105,6 +105,7 @@ async fn schedule_pool_page_candidates( candidates: Vec, sticky_session_token: Option<&str>, effective_pool_config: Option<&AdminProviderPoolConfig>, + provider_model_name: Option<&str>, ) -> ( Vec, Vec, @@ -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 { let mut key_ids = Vec::new(); let mut provider_type_by_key_id = BTreeMap::::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); diff --git a/crates/aether-provider/pool/src/lib.rs b/crates/aether-provider/pool/src/lib.rs index cb147c8f6..58dec5b43 100644 --- a/crates/aether-provider/pool/src/lib.rs +++ b/crates/aether-provider/pool/src/lib.rs @@ -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() diff --git a/crates/aether-provider/pool/src/provider.rs b/crates/aether-provider/pool/src/provider.rs index c25ab672f..3cb16f13e 100644 --- a/crates/aether-provider/pool/src/provider.rs +++ b/crates/aether-provider/pool/src/provider.rs @@ -16,6 +16,7 @@ pub struct ProviderPoolMemberInput<'a> { pub provider_type: &'a str, pub key: &'a StoredProviderCatalogKey, pub auth_config: Option<&'a Map>, + pub provider_model_name: Option<&'a str>, } pub trait ProviderPoolAdapter: Send + Sync { diff --git a/crates/aether-provider/pool/src/providers/antigravity.rs b/crates/aether-provider/pool/src/providers/antigravity.rs index 77e474504..24d9bb797 100644 --- a/crates/aether-provider/pool/src/providers/antigravity.rs +++ b/crates/aether-provider/pool/src/providers/antigravity.rs @@ -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], diff --git a/crates/aether-provider/pool/src/providers/codex.rs b/crates/aether-provider/pool/src/providers/codex.rs index 66f7413a0..0086e813e 100644 --- a/crates/aether-provider/pool/src/providers/codex.rs +++ b/crates/aether-provider/pool/src/providers/codex.rs @@ -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) } diff --git a/crates/aether-provider/pool/src/quota.rs b/crates/aether-provider/pool/src/quota.rs index 86ff4e3d6..5f9c05e6b 100644 --- a/crates/aether-provider/pool/src/quota.rs +++ b/crates/aether-provider/pool/src/quota.rs @@ -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 { + 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| { + 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::>(); + 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, diff --git a/crates/aether-provider/pool/src/service.rs b/crates/aether-provider/pool/src/service.rs index 403783e6d..f61b07a7f 100644 --- a/crates/aether-provider/pool/src/service.rs +++ b/crates/aether-provider/pool/src/service.rs @@ -123,12 +123,14 @@ impl ProviderPoolService { provider_type: &str, key: &StoredProviderCatalogKey, auth_config: Option<&Map>, + 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) } diff --git a/frontend/src/features/pool/components/PoolKeyQuotaPanel.vue b/frontend/src/features/pool/components/PoolKeyQuotaPanel.vue index 829a25aad..ea75573bf 100644 --- a/frontend/src/features/pool/components/PoolKeyQuotaPanel.vue +++ b/frontend/src/features/pool/components/PoolKeyQuotaPanel.vue @@ -8,7 +8,7 @@
diff --git a/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts b/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts index cb70b1729..d2c233e64 100644 --- a/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts +++ b/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts @@ -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() diff --git a/frontend/src/features/providers/utils/__tests__/antigravityQuotaSummary.spec.ts b/frontend/src/features/providers/utils/__tests__/antigravityQuotaSummary.spec.ts new file mode 100644 index 000000000..ee0db2b03 --- /dev/null +++ b/frontend/src/features/providers/utils/__tests__/antigravityQuotaSummary.spec.ts @@ -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') + }) +}) \ No newline at end of file diff --git a/frontend/src/features/providers/utils/antigravityQuota.ts b/frontend/src/features/providers/utils/antigravityQuota.ts index 981ad1252..d27071200 100644 --- a/frontend/src/features/providers/utils/antigravityQuota.ts +++ b/frontend/src/features/providers/utils/antigravityQuota.ts @@ -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 = { '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( + items: T[], +): T[] { + const itemsByGroup = new Map() + 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) +} diff --git a/frontend/src/views/admin/PoolManagement.vue b/frontend/src/views/admin/PoolManagement.vue index de4d72634..15b9507b9 100644 --- a/frontend/src/views/admin/PoolManagement.vue +++ b/frontend/src/views/admin/PoolManagement.vue @@ -212,7 +212,7 @@ :class="getPoolKeyRowClass(key.key_id)" >
- + - + {{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }} - + {{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }} - +
{{ formatPoolScore(key.pool_score?.score) }} @@ -416,7 +416,7 @@
- + - +