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