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