Skip to main content

ai_usagebar/anthropic_api/
vendor.rs

1//! Anthropic Admin API renderer — month-to-date spend, optionally against a
2//! configured monthly limit (the API doesn't expose the limit or the remaining
3//! prepaid balance, so the limit is a config value).
4
5use std::collections::HashMap;
6
7use chrono::{DateTime, Utc};
8
9use crate::format::{placeholders, substitute, updated_at_hm, usd};
10use crate::pacing::PaceSeverity;
11use crate::pango::{color_span, escape, severity_color, severity_for};
12use crate::theme::Theme;
13use crate::tooltip::{Line as TooltipLine, render_bordered};
14use crate::usage::AnthropicApiSnapshot;
15use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
16use crate::waybar::{Class, WaybarOutput};
17
18use super::fetch::FetchOutcome;
19
20pub const DEFAULT_FORMAT: &str = "{aapi_headline}";
21
22/// Bar headline: spend-vs-limit with a % when a limit is configured, otherwise
23/// just the month-to-date spend.
24fn headline(snap: &AnthropicApiSnapshot) -> String {
25    match snap.limit {
26        Some(l) if l > 0.0 => format!(
27            "{} / ${:.0} · {}%",
28            usd(snap.spent),
29            l,
30            snap.pct().unwrap_or(0)
31        ),
32        _ => format!("{}/mo", usd(snap.spent)),
33    }
34}
35
36pub fn build_placeholders(snap: &AnthropicApiSnapshot) -> HashMap<&'static str, String> {
37    let pct = snap.pct().unwrap_or(0);
38    placeholders(vec![
39        ("icon", "󰢗".to_string()),
40        (
41            "vendor_short",
42            VendorId::AnthropicApi.short_name().to_string(),
43        ),
44        // Cross-vendor aliases — spend% maps to the session/weekly slots.
45        ("session_pct", pct.to_string()),
46        ("session_reset", "—".to_string()),
47        ("weekly_pct", pct.to_string()),
48        ("weekly_reset", "—".to_string()),
49        ("plan", "Anthropic API".to_string()),
50        ("aapi_headline", headline(snap)),
51        ("aapi_spent", usd(snap.spent)),
52        (
53            "aapi_limit",
54            snap.limit
55                .map(|l| format!("${l:.0}"))
56                .unwrap_or_else(|| "—".into()),
57        ),
58        ("aapi_pct", pct.to_string()),
59    ])
60}
61
62/// Severity keys on the spend-vs-limit %. With no limit there's no signal, so
63/// it stays calm (low).
64pub fn severity(snap: &AnthropicApiSnapshot) -> PaceSeverity {
65    match snap.pct() {
66        Some(p) => severity_for(p.min(100)),
67        None => PaceSeverity::Low,
68    }
69}
70
71pub fn render(
72    outcome: &VendorOutcome,
73    snap: &AnthropicApiSnapshot,
74    theme: &Theme,
75    opts: &RenderOpts,
76    now: DateTime<Utc>,
77) -> WaybarOutput {
78    let class = Class::from(severity(snap));
79    let format = opts
80        .format
81        .clone()
82        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
83    let values = build_placeholders(snap);
84
85    let mut text = substitute(&format, &values);
86    if outcome.stale {
87        text.push_str(" ⏸");
88    }
89
90    let wrapper_color = severity_color(severity(snap), theme).to_string();
91    let icon_prefix = match opts.icon.as_deref() {
92        Some(ic) if !ic.is_empty() => format!("{ic} "),
93        _ => String::new(),
94    };
95    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
96
97    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
98        substitute(fmt, &values)
99    } else {
100        render_tooltip(outcome, snap, theme, now)
101    };
102
103    WaybarOutput {
104        text: bar_text,
105        tooltip,
106        class,
107    }
108}
109
110fn render_tooltip(
111    outcome: &VendorOutcome,
112    snap: &AnthropicApiSnapshot,
113    theme: &Theme,
114    now: DateTime<Utc>,
115) -> String {
116    let blue = &theme.blue;
117    let dim = &theme.dim;
118    let fg = &theme.fg;
119    let color = severity_color(severity(snap), theme);
120
121    let mut lines: Vec<TooltipLine> = Vec::new();
122    lines.push(TooltipLine::Center(format!(
123        "<span font_weight='bold' foreground='{blue}'>Anthropic API</span>"
124    )));
125    lines.push(TooltipLine::Sep);
126    lines.push(TooltipLine::Body("".into()));
127
128    lines.push(TooltipLine::Body(format!(
129        " <span foreground='{fg}'>  󰉹  Spend this month</span>"
130    )));
131    lines.push(TooltipLine::Body(format!(
132        "   <span font_weight='bold' foreground='{color}'>{spent}</span>",
133        spent = escape(&usd(snap.spent))
134    )));
135    match snap.limit {
136        Some(l) if l > 0.0 => {
137            lines.push(TooltipLine::Body(format!(
138                " <span foreground='{dim}'>     of ${l:.0} limit ({pct}%)</span>",
139                pct = snap.pct().unwrap_or(0)
140            )));
141        }
142        _ => {
143            lines.push(TooltipLine::Body(format!(
144                " <span foreground='{dim}'>     no monthly limit set (add `monthly_limit` under [anthropic_api])</span>"
145            )));
146        }
147    }
148    lines.push(TooltipLine::Body("".into()));
149    lines.push(TooltipLine::Body(format!(
150        " <span foreground='{dim}'>  󰋼  spend consumed, not balance —</span>"
151    )));
152    lines.push(TooltipLine::Body(format!(
153        " <span foreground='{dim}'>     remaining credit is Console-only (no API)</span>"
154    )));
155    lines.push(TooltipLine::Body(format!(
156        " <span foreground='{dim}'>  󰋼  excludes Priority Tier cost, which the</span>"
157    )));
158    lines.push(TooltipLine::Body(format!(
159        " <span foreground='{dim}'>     cost API does not report</span>"
160    )));
161
162    if let Some((code, msg)) = outcome.last_error.as_ref() {
163        // code 0 = a non-HTTP failure (schema drift, transport). Show it under a
164        // "Sync error" header rather than the nonsensical "HTTP 0" — and never
165        // suppress it, so the reason for a stale state is always visible.
166        let (icon, ecolor, header) = if *code == 0 {
167            ("󰀪", theme.orange.as_str(), "Sync error".to_string())
168        } else if *code >= 500 {
169            ("󰅚", theme.red.as_str(), format!("HTTP {code}"))
170        } else {
171            ("󰀪", theme.orange.as_str(), format!("HTTP {code}"))
172        };
173        lines.push(TooltipLine::Body("".into()));
174        lines.push(TooltipLine::Sep);
175        lines.push(TooltipLine::Body(format!(
176            " <span foreground='{ecolor}'>  {icon}  {header}</span>"
177        )));
178        lines.push(TooltipLine::Body(format!(
179            "     <span foreground='{dim}'>{}</span>",
180            escape(msg)
181        )));
182        if *code == 401 || *code == 403 {
183            lines.push(TooltipLine::Body(format!(
184                "     <span foreground='{dim}'>needs an org Admin key (sk-ant-admin01-); set up an</span>"
185            )));
186            lines.push(TooltipLine::Body(format!(
187                "     <span foreground='{dim}'>organization in Console → Settings → Organization</span>"
188            )));
189        }
190    }
191
192    let updated = updated_at_hm(now, outcome.cache_age);
193    lines.push(TooltipLine::Body("".into()));
194    lines.push(TooltipLine::Sep);
195    lines.push(TooltipLine::Body(format!(
196        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
197    )));
198
199    render_bordered(&lines, theme)
200}
201
202impl From<FetchOutcome> for VendorOutcome {
203    fn from(o: FetchOutcome) -> Self {
204        Self {
205            snapshot: crate::usage::VendorSnapshot::AnthropicApi(o.snapshot),
206            stale: o.stale,
207            last_error: o.last_error,
208            cache_age: o.cache_age,
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::usage::AnthropicApiSnapshot;
217
218    fn outcome(spent: f64, limit: Option<f64>) -> (AnthropicApiSnapshot, VendorOutcome) {
219        let snap = AnthropicApiSnapshot { spent, limit };
220        let o = VendorOutcome {
221            snapshot: crate::usage::VendorSnapshot::AnthropicApi(snap.clone()),
222            stale: false,
223            last_error: None,
224            cache_age: Some(std::time::Duration::from_secs(10)),
225        };
226        (snap, o)
227    }
228
229    fn opts() -> RenderOpts {
230        RenderOpts {
231            format: None,
232            tooltip_format: None,
233            icon: None,
234            pace_tolerance: 5,
235            format_pace_color: false,
236            tooltip_pace_pts: false,
237        }
238    }
239
240    #[test]
241    fn headline_with_limit_shows_spend_limit_and_pct() {
242        let (snap, o) = outcome(1.34, Some(1000.0));
243        let out = render(&o, &snap, &Theme::default(), &opts(), Utc::now());
244        assert!(out.text.contains("$1.34 / $1000 · 0%"));
245    }
246
247    #[test]
248    fn headline_without_limit_shows_monthly_spend() {
249        let (snap, o) = outcome(1.34, None);
250        let out = render(&o, &snap, &Theme::default(), &opts(), Utc::now());
251        assert!(out.text.contains("$1.34/mo"));
252        assert!(out.tooltip.contains("no monthly limit set"));
253    }
254
255    #[test]
256    fn severity_scales_with_spend_pct() {
257        assert_eq!(
258            severity(&AnthropicApiSnapshot {
259                spent: 950.0,
260                limit: Some(1000.0)
261            }),
262            PaceSeverity::Critical
263        );
264        assert_eq!(
265            severity(&AnthropicApiSnapshot {
266                spent: 1.0,
267                limit: Some(1000.0)
268            }),
269            PaceSeverity::Low
270        );
271        assert_eq!(
272            severity(&AnthropicApiSnapshot {
273                spent: 500.0,
274                limit: None
275            }),
276            PaceSeverity::Low
277        );
278    }
279}