Skip to main content

ai_usagebar/openrouter/
vendor.rs

1//! OpenRouter renderer — bar text + bordered Pango tooltip.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::format::{placeholders, substitute, updated_at_hm, usd};
8use crate::pacing::PaceSeverity;
9use crate::pango::{self, color_span, escape, severity_color, severity_for};
10use crate::theme::Theme;
11use crate::tooltip::{Line as TooltipLine, render_bordered};
12use crate::usage::OpenRouterSnapshot;
13use crate::vendor::{RenderOpts, VendorOutcome};
14use crate::waybar::{Class, WaybarOutput};
15
16use super::fetch::FetchOutcome;
17
18pub const DEFAULT_FORMAT: &str = "${or_balance} · ${or_used_today}";
19
20/// Build the placeholder map for the OpenRouter snapshot.
21pub fn build_placeholders(snap: &OpenRouterSnapshot) -> HashMap<&'static str, String> {
22    placeholders(vec![
23        ("icon", "󱙺".to_string()),
24        ("vendor_short", "opr".to_string()),
25        // Cross-vendor aliases — for OpenRouter the "session" concept maps
26        // to "credit consumed %", and there's no reset time so we render "—".
27        ("session_pct", snap.consumed_pct().to_string()),
28        ("session_reset", "—".to_string()),
29        ("weekly_pct", snap.consumed_pct().to_string()),
30        ("weekly_reset", "—".to_string()),
31        ("plan", snap.label.clone()),
32        ("or_label", snap.label.clone()),
33        ("or_balance", usd(snap.balance())),
34        ("or_total", usd(snap.total_credits)),
35        ("or_used", usd(snap.total_usage)),
36        ("or_used_today", usd(snap.usage_daily)),
37        ("or_used_week", usd(snap.usage_weekly)),
38        ("or_used_month", usd(snap.usage_monthly)),
39        ("or_consumed_pct", snap.consumed_pct().to_string()),
40        (
41            "or_free_tier",
42            (if snap.is_free_tier { "free" } else { "paid" }).into(),
43        ),
44        (
45            "or_limit",
46            snap.limit.map(usd).unwrap_or_else(|| "unlimited".into()),
47        ),
48        (
49            "or_limit_remaining",
50            snap.limit_remaining
51                .map(usd)
52                .unwrap_or_else(|| "unlimited".into()),
53        ),
54    ])
55}
56
57/// Compose the full Waybar output for an OpenRouter snapshot.
58pub fn render(
59    outcome: &VendorOutcome,
60    snap: &OpenRouterSnapshot,
61    theme: &Theme,
62    opts: &RenderOpts,
63    now: DateTime<Utc>,
64) -> WaybarOutput {
65    let class = Class::from(severity(snap));
66    let format = opts
67        .format
68        .clone()
69        .unwrap_or_else(|| DEFAULT_FORMAT.replace('$', ""));
70    let mut values = build_placeholders(snap);
71    // The default format above uses ${…} (legible in a shell context), so
72    // strip the $ to match our placeholder syntax.
73    values.insert("or_balance_bar", or_balance_bar(snap, theme));
74    // Both sinks fed by this map (bar text and --tooltip-format) end up as
75    // Pango markup, and the label is API-controlled — escape it here, its only
76    // markup insertion point. The default tooltip escapes the raw snapshot
77    // itself, and or_balance_bar is markup we emit, so neither is touched.
78    for key in ["plan", "or_label"] {
79        if let Some(value) = values.get_mut(key) {
80            *value = escape(value);
81        }
82    }
83
84    let format_clean = format.replace("${", "{");
85    let mut text = substitute(&format_clean, &values);
86    if outcome.stale {
87        text.push_str(" ⏸");
88    }
89    let wrapper_color = severity_color(severity(snap), theme).to_string();
90    let icon_prefix = match opts.icon.as_deref() {
91        Some(ic) if !ic.is_empty() => format!("{ic} "),
92        _ => String::new(),
93    };
94    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
95
96    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
97        substitute(&fmt.replace("${", "{"), &values)
98    } else {
99        render_tooltip(outcome, snap, theme, now)
100    };
101
102    WaybarOutput {
103        text: bar_text,
104        tooltip,
105        class,
106    }
107}
108
109fn or_balance_bar(snap: &OpenRouterSnapshot, theme: &Theme) -> String {
110    let color = severity_color(severity(snap), theme);
111    pango::progress_bar(snap.consumed_pct(), color, theme, None)
112}
113
114/// OpenRouter severity is keyed on consumed-percentage (low credit = critical),
115/// with one case the percentage cannot express: an account that owes money.
116/// `consumed_pct` needs `total_credits` as a denominator and reports 0 without
117/// one, so an account that never bought credits but ran up usage would
118/// otherwise show a debt in reassuring green.
119pub fn severity(snap: &OpenRouterSnapshot) -> PaceSeverity {
120    if snap.balance() < 0.0 {
121        return PaceSeverity::Critical;
122    }
123    severity_for(snap.consumed_pct())
124}
125
126fn render_tooltip(
127    outcome: &VendorOutcome,
128    snap: &OpenRouterSnapshot,
129    theme: &Theme,
130    now: DateTime<Utc>,
131) -> String {
132    let blue = &theme.blue;
133    let dim = &theme.dim;
134    let fg = &theme.fg;
135
136    let color = severity_color(severity(snap), theme);
137    let bar = or_balance_bar(snap, theme);
138
139    let mut lines: Vec<TooltipLine> = Vec::new();
140    lines.push(TooltipLine::Center(format!(
141        "<span font_weight='bold' foreground='{blue}'>{label}</span>",
142        label = escape(&snap.label)
143    )));
144    lines.push(TooltipLine::Sep);
145    lines.push(TooltipLine::Body("".into()));
146
147    lines.push(TooltipLine::Body(format!(
148        " <span foreground='{fg}'>  󰢗  Balance</span>"
149    )));
150    lines.push(TooltipLine::Body(format!(
151        "   {bar}  <span font_weight='bold' foreground='{color}'>{bal}</span>",
152        bal = escape(&usd(snap.balance()))
153    )));
154    lines.push(TooltipLine::Body(format!(
155        " <span foreground='{dim}'>  {used} of {total} used ({pct}%)</span>",
156        used = escape(&usd(snap.total_usage)),
157        total = escape(&usd(snap.total_credits)),
158        pct = snap.consumed_pct()
159    )));
160
161    lines.push(TooltipLine::Body("".into()));
162    lines.push(TooltipLine::Body(format!(
163        " <span foreground='{fg}'>  󰸘  Usage</span>"
164    )));
165    lines.push(TooltipLine::Body(format!(
166        " <span foreground='{dim}'>     today {today} · week {week} · month {month}</span>",
167        today = escape(&usd(snap.usage_daily)),
168        week = escape(&usd(snap.usage_weekly)),
169        month = escape(&usd(snap.usage_monthly))
170    )));
171
172    if let Some(limit) = snap.limit {
173        let rem = snap.limit_remaining.unwrap_or(0.0);
174        lines.push(TooltipLine::Body("".into()));
175        lines.push(TooltipLine::Body(format!(
176            " <span foreground='{fg}'>  󱁻  Per-key limit</span>"
177        )));
178        lines.push(TooltipLine::Body(format!(
179            " <span foreground='{dim}'>     {rem} of {tot} remaining</span>",
180            rem = escape(&usd(rem)),
181            tot = escape(&usd(limit))
182        )));
183    }
184
185    let tier_label = if snap.is_free_tier {
186        "free tier"
187    } else {
188        "paid tier"
189    };
190    lines.push(TooltipLine::Body("".into()));
191    lines.push(TooltipLine::Body(format!(
192        " <span foreground='{dim}'>  󰓹  {tier_label}</span>"
193    )));
194
195    if let Some((code, msg)) = outcome.last_error.as_ref()
196        && *code != 0
197    {
198        let (icon, ecolor) = if *code >= 500 {
199            ("󰅚", theme.red.as_str())
200        } else {
201            ("󰀪", theme.orange.as_str())
202        };
203        lines.push(TooltipLine::Body("".into()));
204        lines.push(TooltipLine::Sep);
205        lines.push(TooltipLine::Body(format!(
206            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
207        )));
208        lines.push(TooltipLine::Body(format!(
209            "     <span foreground='{dim}'>{}</span>",
210            escape(msg)
211        )));
212    }
213
214    let updated = updated_at_hm(now, outcome.cache_age);
215    lines.push(TooltipLine::Body("".into()));
216    lines.push(TooltipLine::Sep);
217    lines.push(TooltipLine::Body(format!(
218        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
219    )));
220
221    render_bordered(&lines, theme)
222}
223
224impl From<FetchOutcome> for VendorOutcome {
225    fn from(o: FetchOutcome) -> Self {
226        Self {
227            snapshot: crate::usage::VendorSnapshot::Openrouter(o.snapshot),
228            stale: o.stale,
229            last_error: o.last_error,
230            cache_age: o.cache_age,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::usage::OpenRouterSnapshot;
239
240    fn sample_snap() -> OpenRouterSnapshot {
241        OpenRouterSnapshot {
242            label: "OpenRouter — prod".into(),
243            total_credits: 100.0,
244            total_usage: 25.5,
245            usage_daily: 1.0,
246            usage_weekly: 7.0,
247            usage_monthly: 25.5,
248            is_free_tier: false,
249            limit: Some(50.0),
250            limit_remaining: Some(24.5),
251        }
252    }
253
254    fn sample_outcome(snap: OpenRouterSnapshot) -> VendorOutcome {
255        VendorOutcome {
256            snapshot: crate::usage::VendorSnapshot::Openrouter(snap),
257            stale: false,
258            last_error: None,
259            cache_age: Some(std::time::Duration::from_secs(15)),
260        }
261    }
262
263    fn opts() -> RenderOpts {
264        RenderOpts {
265            format: None,
266            tooltip_format: None,
267            icon: None,
268            pace_tolerance: 5,
269            format_pace_color: false,
270            tooltip_pace_pts: false,
271        }
272    }
273
274    #[test]
275    fn api_controlled_label_is_pango_escaped_in_custom_formats() {
276        // The label comes from the API and both sinks fed by the placeholder
277        // map are Pango markup. Unescaped, a label like `a & b <span…>` either
278        // breaks the markup or lets an attacker-shaped key name paint text the
279        // widget never intended.
280        let mut snap = sample_snap();
281        snap.label = "A & B <b>spoof</b>".into();
282
283        let mut o = opts();
284        o.format = Some("{plan}|{or_label}".into());
285        let out = render(
286            &sample_outcome(snap.clone()),
287            &snap,
288            &Theme::default(),
289            &o,
290            Utc::now(),
291        );
292        assert!(
293            out.text.contains("A &amp; B &lt;b&gt;spoof&lt;/b&gt;"),
294            "label was not escaped: {}",
295            out.text
296        );
297        assert!(!out.text.contains("<b>spoof</b>"));
298
299        // ...and the same through the tooltip sink.
300        let mut o2 = opts();
301        o2.tooltip_format = Some("{or_label}".into());
302        let out2 = render(
303            &sample_outcome(snap.clone()),
304            &snap,
305            &Theme::default(),
306            &o2,
307            Utc::now(),
308        );
309        assert!(out2.tooltip.contains("A &amp; B"));
310        assert!(!out2.tooltip.contains("<b>spoof</b>"));
311    }
312
313    #[test]
314    fn default_render_contains_balance_and_today_usage() {
315        let snap = sample_snap();
316        let outcome = sample_outcome(snap.clone());
317        let theme = Theme::default();
318        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
319        assert!(out.text.contains("$74.50"));
320        assert!(out.text.contains("$1.00"));
321    }
322
323    #[test]
324    fn tooltip_includes_balance_usage_tier() {
325        let snap = sample_snap();
326        let outcome = sample_outcome(snap.clone());
327        let theme = Theme::default();
328        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
329        assert!(out.tooltip.contains("Balance"));
330        assert!(out.tooltip.contains("$25.50 of $100.00 used"));
331        assert!(out.tooltip.contains("paid tier"));
332        assert!(out.tooltip.contains("Per-key limit"));
333        assert!(out.tooltip.contains("$24.50 of $50.00"));
334    }
335
336    /// Reported as #118: an account that never bought credits but ran up usage
337    /// showed a reassuring `$0.00` in green. Both halves were wrong — the
338    /// number was clamped, and the severity came from a percentage that has no
339    /// denominator to work with.
340    #[test]
341    fn a_debt_is_shown_as_a_debt_not_as_zero() {
342        let mut snap = sample_snap();
343        snap.total_credits = 0.0;
344        snap.total_usage = 5.71;
345        assert!((snap.balance() + 5.71).abs() < 1e-9);
346        assert_eq!(snap.consumed_pct(), 0);
347        assert_eq!(severity(&snap), PaceSeverity::Critical);
348
349        let out = render(
350            &sample_outcome(snap.clone()),
351            &snap,
352            &Theme::default(),
353            &opts(),
354            Utc::now(),
355        );
356        assert!(out.text.contains("-$5.71"), "{}", out.text);
357        assert!(!out.text.contains("$0.00"), "{}", out.text);
358        assert!(out.tooltip.contains("-$5.71"), "{}", out.tooltip);
359    }
360
361    /// The other way into debt: credits were bought, then overrun. Here the
362    /// percentage already saturates at 100, so only the clamped number was
363    /// wrong — but it must render identically.
364    #[test]
365    fn overrunning_purchased_credit_is_also_a_debt() {
366        let mut snap = sample_snap();
367        snap.total_credits = 10.0;
368        snap.total_usage = 12.5;
369        assert!((snap.balance() + 2.5).abs() < 1e-9);
370        assert_eq!(snap.consumed_pct(), 100);
371        assert_eq!(severity(&snap), PaceSeverity::Critical);
372
373        let out = render(
374            &sample_outcome(snap.clone()),
375            &snap,
376            &Theme::default(),
377            &opts(),
378            Utc::now(),
379        );
380        assert!(out.text.contains("-$2.50"), "{}", out.text);
381    }
382
383    /// A free-tier account that has spent nothing is not in debt, and must
384    /// stay green — the fix keys on the balance, not on "credits are zero".
385    #[test]
386    fn a_zero_credit_account_with_no_usage_stays_low() {
387        let mut snap = sample_snap();
388        snap.total_credits = 0.0;
389        snap.total_usage = 0.0;
390        assert_eq!(snap.balance(), 0.0);
391        assert_eq!(severity(&snap), PaceSeverity::Low);
392    }
393
394    #[test]
395    fn free_tier_label() {
396        let mut snap = sample_snap();
397        snap.is_free_tier = true;
398        let outcome = sample_outcome(snap.clone());
399        let theme = Theme::default();
400        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
401        assert!(out.tooltip.contains("free tier"));
402    }
403
404    #[test]
405    fn stale_appends_pause() {
406        let snap = sample_snap();
407        let mut outcome = sample_outcome(snap.clone());
408        outcome.stale = true;
409        let theme = Theme::default();
410        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
411        assert!(out.text.contains("⏸"));
412    }
413
414    #[test]
415    fn custom_tooltip_uses_placeholders() {
416        let snap = sample_snap();
417        let outcome = sample_outcome(snap.clone());
418        let theme = Theme::default();
419        let mut o = opts();
420        o.tooltip_format = Some("bal: {or_balance} | mtd: {or_used_month}".into());
421        let out = render(&outcome, &snap, &theme, &o, Utc::now());
422        assert_eq!(out.tooltip, "bal: $74.50 | mtd: $25.50");
423    }
424
425    #[test]
426    fn severity_keys_on_consumed_pct() {
427        let mut snap = sample_snap();
428        snap.total_usage = 92.0; // 92% consumed → critical
429        assert_eq!(severity(&snap), PaceSeverity::Critical);
430        snap.total_usage = 60.0; // mid
431        assert_eq!(severity(&snap), PaceSeverity::Mid);
432    }
433}