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