ai_usagebar/anthropic_api/
vendor.rs1use 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
22fn 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 ("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
62pub 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 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}