1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5
6use crate::error::SpendPanelError;
7use crate::model::{CostSnapshot, NamedRateWindow, RateWindow, RateWindowStatus, UsageSnapshot};
8use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
9
10#[derive(Debug, serde::Deserialize)]
11struct QuotaStatsResponse {
12 providers: HashMap<String, ProviderStats>,
13 summary: Option<Summary>,
14}
15
16#[derive(Debug, serde::Deserialize)]
17struct Summary {
18 total_requests: Option<u64>,
19 total_tokens: Option<u64>,
20 approx_cost: Option<f64>,
21}
22
23#[derive(Debug, serde::Deserialize)]
24struct ProviderStats {
25 credential_count: Option<u64>,
26 active_count: Option<u64>,
27 exhausted_count: Option<u64>,
28 total_requests: Option<u64>,
29 tokens: Option<TokenStats>,
30 approx_cost: Option<f64>,
31 quota_groups: Option<QuotaGroups>,
32}
33
34#[derive(Debug, serde::Deserialize)]
35struct TokenStats {
36 input_cached: Option<u64>,
37 input_uncached: Option<u64>,
38 output: Option<u64>,
39}
40
41#[derive(Debug, serde::Deserialize)]
42#[serde(untagged)]
43enum QuotaGroups {
44 List(Vec<QuotaGroup>),
45 Map(HashMap<String, QuotaGroup>),
46}
47
48impl QuotaGroups {
49 fn values(&self) -> Vec<&QuotaGroup> {
50 match self {
51 Self::List(items) => items.iter().collect(),
52 Self::Map(map) => map.values().collect(),
53 }
54 }
55}
56
57#[derive(Debug, serde::Deserialize)]
58struct QuotaGroup {
59 remaining_percent: Option<f64>,
60 reset_time: Option<String>,
61}
62
63#[derive(Debug, Clone, PartialEq)]
64struct ProviderSummary {
65 name: String,
66 requests: u64,
67 tokens: u64,
68 approximate_cost_usd: Option<f64>,
69}
70
71#[derive(Debug, Clone, PartialEq)]
72struct LlmProxyUsage {
73 provider_count: usize,
74 credential_count: u64,
75 active_credential_count: u64,
76 exhausted_credential_count: u64,
77 total_requests: u64,
78 total_tokens: u64,
79 approximate_cost_usd: Option<f64>,
80 minimum_remaining_percent: Option<f64>,
81 next_reset_at: Option<DateTime<Utc>>,
82 top_providers: Vec<ProviderSummary>,
83}
84
85pub struct LlmProxyProvider {
87 metadata: ProviderMetadata,
88 base_url: Option<String>,
89}
90
91impl LlmProxyProvider {
92 pub fn new() -> Self {
93 Self {
94 metadata: ProviderMetadata {
95 id: "llmproxy",
96 name: "LLM Proxy",
97 description: "LLM Proxy quota-stats monitor",
98 auth_methods: &["api_key", "base_url", "env"],
99 website: None,
100 },
101 base_url: None,
102 }
103 }
104
105 pub fn with_base_url(url: &str) -> Self {
106 let mut p = Self::new();
107 p.base_url = Some(url.to_string());
108 p
109 }
110
111 fn clean(raw: &str) -> String {
112 let mut value = raw.trim();
113 if value.len() >= 2
114 && ((value.starts_with('"') && value.ends_with('"'))
115 || (value.starts_with('\'') && value.ends_with('\'')))
116 {
117 value = &value[1..value.len() - 1];
118 }
119 value.trim().to_string()
120 }
121
122 fn resolve_api_key(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
123 for key in ["api_key", "token"] {
124 if let Some(value) = ctx.config.get(key) {
125 let cleaned = Self::clean(value);
126 if !cleaned.is_empty() {
127 return Ok(cleaned);
128 }
129 }
130 }
131 if let Ok(value) = std::env::var("LLM_PROXY_API_KEY") {
132 let cleaned = Self::clean(&value);
133 if !cleaned.is_empty() {
134 return Ok(cleaned);
135 }
136 }
137 Err(SpendPanelError::AuthFailed(
138 "llmproxy".into(),
139 "no API key found in config, token, or LLM_PROXY_API_KEY".into(),
140 ))
141 }
142
143 fn resolve_base_url(&self, ctx: &ProviderContext) -> Result<String, SpendPanelError> {
144 let value = ctx
145 .config
146 .get("base_url")
147 .or_else(|| ctx.config.get("enterprise_host"))
148 .map(String::as_str)
149 .filter(|v| !v.is_empty())
150 .map(Self::clean)
151 .or_else(|| {
152 std::env::var("LLM_PROXY_BASE_URL")
153 .ok()
154 .map(|v| Self::clean(&v))
155 })
156 .or_else(|| self.base_url.clone())
157 .ok_or_else(|| {
158 SpendPanelError::ConfigError(
159 "llmproxy requires base_url/enterprise_host or LLM_PROXY_BASE_URL".into(),
160 )
161 })?;
162
163 let base = if value.starts_with("http://") || value.starts_with("https://") {
164 value
165 } else {
166 format!("https://{}", value)
167 };
168 Ok(base.trim_end_matches('/').to_string())
169 }
170
171 fn quota_stats_url(base_url: &str) -> String {
172 let base = base_url.trim_end_matches('/');
173 if base.ends_with("/v1") {
174 format!("{}/quota-stats", base)
175 } else {
176 format!("{}/v1/quota-stats", base)
177 }
178 }
179
180 fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
181 reqwest::Client::builder()
182 .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
183 .build()
184 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
185 }
186
187 async fn fetch_stats(
188 client: &reqwest::Client,
189 url: String,
190 api_key: &str,
191 ) -> Result<QuotaStatsResponse, SpendPanelError> {
192 let resp = client
193 .get(url)
194 .header("Authorization", format!("Bearer {}", api_key))
195 .header("Accept", "application/json")
196 .send()
197 .await
198 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
199 let status = resp.status();
200 let body = resp
201 .text()
202 .await
203 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
204 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
205 return Err(SpendPanelError::AuthFailed(
206 "llmproxy".into(),
207 format!("invalid API key (HTTP {})", status.as_u16()),
208 ));
209 }
210 if !status.is_success() {
211 return Err(SpendPanelError::ProviderError(
212 "llmproxy".into(),
213 format!("HTTP {}: {}", status, body),
214 ));
215 }
216 serde_json::from_str(&body)
217 .map_err(|e| SpendPanelError::ParseError("llmproxy".into(), e.to_string()))
218 }
219
220 fn token_total(tokens: Option<&TokenStats>) -> u64 {
221 tokens
222 .map(|t| {
223 t.input_cached.unwrap_or(0) + t.input_uncached.unwrap_or(0) + t.output.unwrap_or(0)
224 })
225 .unwrap_or(0)
226 }
227
228 fn parse_reset(raw: Option<&str>) -> Option<DateTime<Utc>> {
229 raw.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
230 .map(|dt| dt.with_timezone(&Utc))
231 }
232
233 fn parse_usage(resp: QuotaStatsResponse) -> LlmProxyUsage {
234 let mut top_providers: Vec<ProviderSummary> = resp
235 .providers
236 .iter()
237 .map(|(name, stats)| ProviderSummary {
238 name: name.clone(),
239 requests: stats.total_requests.unwrap_or(0),
240 tokens: Self::token_total(stats.tokens.as_ref()),
241 approximate_cost_usd: stats.approx_cost,
242 })
243 .collect();
244 top_providers.sort_by(|a, b| {
245 b.requests
246 .cmp(&a.requests)
247 .then_with(|| a.name.cmp(&b.name))
248 });
249
250 let total_requests = resp
251 .summary
252 .as_ref()
253 .and_then(|s| s.total_requests)
254 .unwrap_or_else(|| top_providers.iter().map(|p| p.requests).sum());
255 let total_tokens = resp
256 .summary
257 .as_ref()
258 .and_then(|s| s.total_tokens)
259 .unwrap_or_else(|| top_providers.iter().map(|p| p.tokens).sum());
260 let approximate_cost_usd =
261 resp.summary
262 .as_ref()
263 .and_then(|s| s.approx_cost)
264 .or_else(|| {
265 let sum: f64 = top_providers
266 .iter()
267 .filter_map(|p| p.approximate_cost_usd)
268 .sum();
269 (sum > 0.0).then_some(sum)
270 });
271
272 let quota_groups: Vec<&QuotaGroup> = resp
273 .providers
274 .values()
275 .filter_map(|stats| stats.quota_groups.as_ref())
276 .flat_map(QuotaGroups::values)
277 .collect();
278 let minimum_remaining_percent = quota_groups
279 .iter()
280 .filter_map(|g| g.remaining_percent)
281 .min_by(|a, b| a.total_cmp(b));
282 let next_reset_at = quota_groups
283 .iter()
284 .filter_map(|g| Self::parse_reset(g.reset_time.as_deref()))
285 .min();
286
287 LlmProxyUsage {
288 provider_count: resp.providers.len(),
289 credential_count: resp
290 .providers
291 .values()
292 .map(|s| s.credential_count.unwrap_or(0))
293 .sum(),
294 active_credential_count: resp
295 .providers
296 .values()
297 .map(|s| s.active_count.unwrap_or(0))
298 .sum(),
299 exhausted_credential_count: resp
300 .providers
301 .values()
302 .map(|s| s.exhausted_count.unwrap_or(0))
303 .sum(),
304 total_requests,
305 total_tokens,
306 approximate_cost_usd,
307 minimum_remaining_percent,
308 next_reset_at,
309 top_providers,
310 }
311 }
312
313 fn format_int(value: u64) -> String {
314 let s = value.to_string();
315 let mut out = String::new();
316 for (i, ch) in s.chars().rev().enumerate() {
317 if i > 0 && i % 3 == 0 {
318 out.push(',');
319 }
320 out.push(ch);
321 }
322 out.chars().rev().collect()
323 }
324
325 fn zero_window(label: impl Into<String>) -> RateWindow {
326 RateWindow {
327 label: label.into(),
328 window_minutes: 0,
329 usage_ratio: 0.0,
330 limit: None,
331 used: None,
332 remaining: None,
333 resets_at: None,
334 status: RateWindowStatus::Normal,
335 }
336 }
337
338 fn snapshot_from_usage(usage: LlmProxyUsage) -> UsageSnapshot {
339 let mut snapshot = UsageSnapshot::new("llmproxy");
340 if let Some(remaining) = usage.minimum_remaining_percent {
341 let ratio = ((100.0 - remaining) / 100.0).clamp(0.0, 1.0);
342 snapshot.primary_rate_window = Some(RateWindow {
343 label: format!("Quota ({} active keys)", usage.active_credential_count),
344 window_minutes: 0,
345 usage_ratio: ratio,
346 limit: Some(100),
347 used: Some((ratio * 100.0).round() as u64),
348 remaining: Some(remaining.round().max(0.0) as u64),
349 resets_at: usage.next_reset_at,
350 status: RateWindowStatus::from_ratio(ratio),
351 });
352 }
353 snapshot.secondary_rate_window = Some(Self::zero_window(format!(
354 "Requests {}",
355 Self::format_int(usage.total_requests)
356 )));
357 snapshot.tertiary_rate_window = Some(Self::zero_window(format!(
358 "Tokens {}",
359 Self::format_int(usage.total_tokens)
360 )));
361 snapshot.extra_rate_windows = usage
362 .top_providers
363 .iter()
364 .take(3)
365 .map(|p| NamedRateWindow {
366 id: p.name.clone(),
367 label: p.name.clone(),
368 window: Self::zero_window(format!(
369 "{}: {} req · {} tok{}",
370 p.name,
371 Self::format_int(p.requests),
372 Self::format_int(p.tokens),
373 p.approximate_cost_usd
374 .map(|c| format!(" · ${:.2}", c))
375 .unwrap_or_default()
376 )),
377 })
378 .collect();
379 if let Some(cost) = usage.approximate_cost_usd {
380 snapshot.cost = Some(CostSnapshot {
381 total_cost: Some(cost),
382 currency: "USD".into(),
383 daily_costs: Vec::new(),
384 spend_limit: None,
385 });
386 }
387 snapshot.plan = Some(crate::model::PlanInfo {
388 name: format!(
389 "{} providers, {}/{} active keys",
390 usage.provider_count, usage.active_credential_count, usage.credential_count
391 ),
392 tier: None,
393 features: vec![format!(
394 "{} exhausted keys",
395 usage.exhausted_credential_count
396 )],
397 price: None,
398 currency: None,
399 billing_period: None,
400 });
401 snapshot
402 }
403}
404
405impl Default for LlmProxyProvider {
406 fn default() -> Self {
407 Self::new()
408 }
409}
410
411#[async_trait]
412impl UsageProvider for LlmProxyProvider {
413 fn metadata(&self) -> &ProviderMetadata {
414 &self.metadata
415 }
416
417 fn detect_credentials(&self) -> bool {
418 std::env::var("LLM_PROXY_API_KEY").is_ok_and(|v| !Self::clean(&v).is_empty())
419 && std::env::var("LLM_PROXY_BASE_URL").is_ok_and(|v| !Self::clean(&v).is_empty())
420 }
421
422 async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
423 let api_key = Self::resolve_api_key(ctx)?;
424 let base_url = self.resolve_base_url(ctx)?;
425 let client = Self::build_client(ctx)?;
426 let stats = Self::fetch_stats(&client, Self::quota_stats_url(&base_url), &api_key).await?;
427 Ok(Self::snapshot_from_usage(Self::parse_usage(stats)))
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use pretty_assertions::assert_eq;
435 use wiremock::matchers::{header, method, path};
436 use wiremock::{Mock, MockServer, ResponseTemplate};
437
438 const SAMPLE: &str = r#"{
439 "providers": {
440 "openai": {
441 "credential_count": 3,
442 "active_count": 2,
443 "exhausted_count": 1,
444 "total_requests": 120,
445 "tokens": {"input_cached": 1000, "input_uncached": 2000, "output": 3000},
446 "approx_cost": 12.5,
447 "quota_groups": {"default": {"remaining_percent": 42, "reset_time": "2026-05-18T12:00:00Z"}}
448 },
449 "anthropic": {
450 "credential_count": 1,
451 "active_count": 1,
452 "exhausted_count": 0,
453 "total_requests": 40,
454 "tokens": {"input_cached": 0, "input_uncached": 500, "output": 500},
455 "approx_cost": 3.0,
456 "quota_groups": [{"remaining_percent": 80}]
457 }
458 },
459 "summary": {"total_requests":160, "total_tokens":7000, "approx_cost":15.5}
460 }"#;
461
462 fn parsed_sample() -> QuotaStatsResponse {
463 serde_json::from_str(SAMPLE).unwrap()
464 }
465
466 #[test]
467 fn test_provider_metadata() {
468 let meta = LlmProxyProvider::new().metadata().clone();
469 assert_eq!(meta.id, "llmproxy");
470 assert_eq!(meta.name, "LLM Proxy");
471 }
472
473 #[test]
474 fn test_quota_stats_url_accepts_versioned_or_root_base_urls() {
475 assert_eq!(
476 LlmProxyProvider::quota_stats_url("https://proxy.example.com"),
477 "https://proxy.example.com/v1/quota-stats"
478 );
479 assert_eq!(
480 LlmProxyProvider::quota_stats_url("https://proxy.example.com/v1"),
481 "https://proxy.example.com/v1/quota-stats"
482 );
483 }
484
485 #[test]
486 fn test_parse_quota_stats_summary() {
487 let usage = LlmProxyProvider::parse_usage(parsed_sample());
488 assert_eq!(usage.provider_count, 2);
489 assert_eq!(usage.credential_count, 4);
490 assert_eq!(usage.active_credential_count, 3);
491 assert_eq!(usage.exhausted_credential_count, 1);
492 assert_eq!(usage.total_requests, 160);
493 assert_eq!(usage.total_tokens, 7000);
494 assert_eq!(usage.approximate_cost_usd, Some(15.5));
495 assert_eq!(usage.minimum_remaining_percent, Some(42.0));
496 assert_eq!(usage.top_providers[0].name, "openai");
497 }
498
499 #[test]
500 fn test_snapshot_from_usage() {
501 let snapshot =
502 LlmProxyProvider::snapshot_from_usage(LlmProxyProvider::parse_usage(parsed_sample()));
503 assert_eq!(
504 snapshot.primary_rate_window.as_ref().unwrap().usage_ratio,
505 0.58
506 );
507 assert_eq!(
508 snapshot.secondary_rate_window.unwrap().label,
509 "Requests 160"
510 );
511 assert_eq!(snapshot.tertiary_rate_window.unwrap().label, "Tokens 7,000");
512 assert_eq!(snapshot.cost.unwrap().total_cost, Some(15.5));
513 assert_eq!(snapshot.extra_rate_windows.len(), 2);
514 }
515
516 #[tokio::test]
517 async fn test_fetch_usage_success() {
518 let server = MockServer::start().await;
519 Mock::given(method("GET"))
520 .and(path("/v1/quota-stats"))
521 .and(header("authorization", "Bearer proxy-key"))
522 .and(header("accept", "application/json"))
523 .respond_with(ResponseTemplate::new(200).set_body_raw(SAMPLE, "application/json"))
524 .mount(&server)
525 .await;
526
527 let provider = LlmProxyProvider::with_base_url(&server.uri());
528 let snapshot = provider
529 .fetch_usage(&ProviderContext::with_api_key("proxy-key"))
530 .await
531 .unwrap();
532 assert_eq!(snapshot.primary_rate_window.unwrap().usage_ratio, 0.58);
533 assert_eq!(snapshot.cost.unwrap().total_cost, Some(15.5));
534 }
535
536 #[tokio::test]
537 async fn test_fetch_usage_401_is_auth_failed() {
538 let server = MockServer::start().await;
539 Mock::given(method("GET"))
540 .and(path("/v1/quota-stats"))
541 .respond_with(ResponseTemplate::new(401))
542 .mount(&server)
543 .await;
544 let provider = LlmProxyProvider::with_base_url(&server.uri());
545 let err = provider
546 .fetch_usage(&ProviderContext::with_api_key("bad"))
547 .await
548 .unwrap_err();
549 assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
550 }
551}