Skip to main content

usage_monitor_cli/provider/
llmproxy.rs

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
85/// LLM Proxy quota-stats provider.
86pub 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}