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        o.map(crate::usage::VendorSnapshot::AnthropicApi)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::usage::AnthropicApiSnapshot;
212
213    fn outcome(spent: f64, limit: Option<f64>) -> (AnthropicApiSnapshot, VendorOutcome) {
214        let snap = AnthropicApiSnapshot { spent, limit };
215        let o = VendorOutcome {
216            snapshot: crate::usage::VendorSnapshot::AnthropicApi(snap.clone()),
217            stale: false,
218            last_error: None,
219            cache_age: Some(std::time::Duration::from_secs(10)),
220        };
221        (snap, o)
222    }
223
224    fn opts() -> RenderOpts {
225        RenderOpts {
226            format: None,
227            tooltip_format: None,
228            icon: None,
229            pace_tolerance: 5,
230            format_pace_color: false,
231            tooltip_pace_pts: false,
232        }
233    }
234
235    #[test]
236    fn headline_with_limit_shows_spend_limit_and_pct() {
237        let (snap, o) = outcome(1.34, Some(1000.0));
238        let out = render(&o, &snap, &Theme::default(), &opts(), Utc::now());
239        assert!(out.text.contains("$1.34 / $1000 · 0%"));
240    }
241
242    #[test]
243    fn headline_without_limit_shows_monthly_spend() {
244        let (snap, o) = outcome(1.34, None);
245        let out = render(&o, &snap, &Theme::default(), &opts(), Utc::now());
246        assert!(out.text.contains("$1.34/mo"));
247        assert!(out.tooltip.contains("no monthly limit set"));
248    }
249
250    #[test]
251    fn severity_scales_with_spend_pct() {
252        assert_eq!(
253            severity(&AnthropicApiSnapshot {
254                spent: 950.0,
255                limit: Some(1000.0)
256            }),
257            PaceSeverity::Critical
258        );
259        assert_eq!(
260            severity(&AnthropicApiSnapshot {
261                spent: 1.0,
262                limit: Some(1000.0)
263            }),
264            PaceSeverity::Low
265        );
266        assert_eq!(
267            severity(&AnthropicApiSnapshot {
268                spent: 500.0,
269                limit: None
270            }),
271            PaceSeverity::Low
272        );
273    }
274}