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