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, 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 ("vendor_short", "aac".to_string()),
41 ("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
59pub 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 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}