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};
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", format_money(snap.balance())),
34        ("or_total", format_money(snap.total_credits)),
35        ("or_used", format_money(snap.total_usage)),
36        ("or_used_today", format_money(snap.usage_daily)),
37        ("or_used_week", format_money(snap.usage_weekly)),
38        ("or_used_month", format_money(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
47                .map(format_money)
48                .unwrap_or_else(|| "unlimited".into()),
49        ),
50        (
51            "or_limit_remaining",
52            snap.limit_remaining
53                .map(format_money)
54                .unwrap_or_else(|| "unlimited".into()),
55        ),
56    ])
57}
58
59fn format_money(v: f64) -> String {
60    if v < 0.0 {
61        format!("-${:.2}", -v)
62    } else {
63        format!("${v:.2}")
64    }
65}
66
67/// Compose the full Waybar output for an OpenRouter snapshot.
68pub fn render(
69    outcome: &VendorOutcome,
70    snap: &OpenRouterSnapshot,
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.replace('$', ""));
80    let mut values = build_placeholders(snap);
81    // The default format above uses ${…} (legible in a shell context), so
82    // strip the $ to match our placeholder syntax.
83    values.insert("or_balance_bar", or_balance_bar(snap, theme));
84    // Both sinks fed by this map (bar text and --tooltip-format) end up as
85    // Pango markup, and the label is API-controlled — escape it here, its only
86    // markup insertion point. The default tooltip escapes the raw snapshot
87    // itself, and or_balance_bar is markup we emit, so neither is touched.
88    for key in ["plan", "or_label"] {
89        if let Some(value) = values.get_mut(key) {
90            *value = escape(value);
91        }
92    }
93
94    let format_clean = format.replace("${", "{");
95    let mut text = substitute(&format_clean, &values);
96    if outcome.stale {
97        text.push_str(" ⏸");
98    }
99    let wrapper_color = severity_color(severity(snap), theme).to_string();
100    let icon_prefix = match opts.icon.as_deref() {
101        Some(ic) if !ic.is_empty() => format!("{ic} "),
102        _ => String::new(),
103    };
104    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
105
106    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
107        substitute(&fmt.replace("${", "{"), &values)
108    } else {
109        render_tooltip(outcome, snap, theme, now)
110    };
111
112    WaybarOutput {
113        text: bar_text,
114        tooltip,
115        class,
116    }
117}
118
119fn or_balance_bar(snap: &OpenRouterSnapshot, theme: &Theme) -> String {
120    let pct = snap.consumed_pct();
121    let color = severity_color(severity_for(pct), theme);
122    pango::progress_bar(pct, color, theme, None)
123}
124
125/// OpenRouter severity is keyed on consumed-percentage (low credit = critical).
126pub fn severity(snap: &OpenRouterSnapshot) -> PaceSeverity {
127    severity_for(snap.consumed_pct())
128}
129
130fn render_tooltip(
131    outcome: &VendorOutcome,
132    snap: &OpenRouterSnapshot,
133    theme: &Theme,
134    now: DateTime<Utc>,
135) -> String {
136    let blue = &theme.blue;
137    let dim = &theme.dim;
138    let fg = &theme.fg;
139
140    let color = severity_color(severity(snap), theme);
141    let bar = or_balance_bar(snap, theme);
142
143    let mut lines: Vec<TooltipLine> = Vec::new();
144    lines.push(TooltipLine::Center(format!(
145        "<span font_weight='bold' foreground='{blue}'>{label}</span>",
146        label = escape(&snap.label)
147    )));
148    lines.push(TooltipLine::Sep);
149    lines.push(TooltipLine::Body("".into()));
150
151    lines.push(TooltipLine::Body(format!(
152        " <span foreground='{fg}'>  󰢗  Balance</span>"
153    )));
154    lines.push(TooltipLine::Body(format!(
155        "   {bar}  <span font_weight='bold' foreground='{color}'>{bal}</span>",
156        bal = escape(&format_money(snap.balance()))
157    )));
158    lines.push(TooltipLine::Body(format!(
159        " <span foreground='{dim}'>  {used} of {total} used ({pct}%)</span>",
160        used = escape(&format_money(snap.total_usage)),
161        total = escape(&format_money(snap.total_credits)),
162        pct = snap.consumed_pct()
163    )));
164
165    lines.push(TooltipLine::Body("".into()));
166    lines.push(TooltipLine::Body(format!(
167        " <span foreground='{fg}'>  󰸘  Usage</span>"
168    )));
169    lines.push(TooltipLine::Body(format!(
170        " <span foreground='{dim}'>     today {today} · week {week} · month {month}</span>",
171        today = escape(&format_money(snap.usage_daily)),
172        week = escape(&format_money(snap.usage_weekly)),
173        month = escape(&format_money(snap.usage_monthly))
174    )));
175
176    if let Some(limit) = snap.limit {
177        let rem = snap.limit_remaining.unwrap_or(0.0);
178        lines.push(TooltipLine::Body("".into()));
179        lines.push(TooltipLine::Body(format!(
180            " <span foreground='{fg}'>  󱁻  Per-key limit</span>"
181        )));
182        lines.push(TooltipLine::Body(format!(
183            " <span foreground='{dim}'>     {rem} of {tot} remaining</span>",
184            rem = escape(&format_money(rem)),
185            tot = escape(&format_money(limit))
186        )));
187    }
188
189    let tier_label = if snap.is_free_tier {
190        "free tier"
191    } else {
192        "paid tier"
193    };
194    lines.push(TooltipLine::Body("".into()));
195    lines.push(TooltipLine::Body(format!(
196        " <span foreground='{dim}'>  󰓹  {tier_label}</span>"
197    )));
198
199    if let Some((code, msg)) = outcome.last_error.as_ref()
200        && *code != 0
201    {
202        let (icon, ecolor) = if *code >= 500 {
203            ("󰅚", theme.red.as_str())
204        } else {
205            ("󰀪", theme.orange.as_str())
206        };
207        lines.push(TooltipLine::Body("".into()));
208        lines.push(TooltipLine::Sep);
209        lines.push(TooltipLine::Body(format!(
210            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
211        )));
212        lines.push(TooltipLine::Body(format!(
213            "     <span foreground='{dim}'>{}</span>",
214            escape(msg)
215        )));
216    }
217
218    let updated = updated_at_hm(now, outcome.cache_age);
219    lines.push(TooltipLine::Body("".into()));
220    lines.push(TooltipLine::Sep);
221    lines.push(TooltipLine::Body(format!(
222        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
223    )));
224
225    render_bordered(&lines, theme)
226}
227
228impl From<FetchOutcome> for VendorOutcome {
229    fn from(o: FetchOutcome) -> Self {
230        Self {
231            snapshot: crate::usage::VendorSnapshot::Openrouter(o.snapshot),
232            stale: o.stale,
233            last_error: o.last_error,
234            cache_age: o.cache_age,
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::usage::OpenRouterSnapshot;
243
244    fn sample_snap() -> OpenRouterSnapshot {
245        OpenRouterSnapshot {
246            label: "OpenRouter — prod".into(),
247            total_credits: 100.0,
248            total_usage: 25.5,
249            usage_daily: 1.0,
250            usage_weekly: 7.0,
251            usage_monthly: 25.5,
252            is_free_tier: false,
253            limit: Some(50.0),
254            limit_remaining: Some(24.5),
255        }
256    }
257
258    fn sample_outcome(snap: OpenRouterSnapshot) -> VendorOutcome {
259        VendorOutcome {
260            snapshot: crate::usage::VendorSnapshot::Openrouter(snap),
261            stale: false,
262            last_error: None,
263            cache_age: Some(std::time::Duration::from_secs(15)),
264        }
265    }
266
267    fn opts() -> RenderOpts {
268        RenderOpts {
269            format: None,
270            tooltip_format: None,
271            icon: None,
272            pace_tolerance: 5,
273            format_pace_color: false,
274            tooltip_pace_pts: false,
275        }
276    }
277
278    #[test]
279    fn api_controlled_label_is_pango_escaped_in_custom_formats() {
280        // The label comes from the API and both sinks fed by the placeholder
281        // map are Pango markup. Unescaped, a label like `a & b <span…>` either
282        // breaks the markup or lets an attacker-shaped key name paint text the
283        // widget never intended.
284        let mut snap = sample_snap();
285        snap.label = "A & B <b>spoof</b>".into();
286
287        let mut o = opts();
288        o.format = Some("{plan}|{or_label}".into());
289        let out = render(
290            &sample_outcome(snap.clone()),
291            &snap,
292            &Theme::default(),
293            &o,
294            Utc::now(),
295        );
296        assert!(
297            out.text.contains("A &amp; B &lt;b&gt;spoof&lt;/b&gt;"),
298            "label was not escaped: {}",
299            out.text
300        );
301        assert!(!out.text.contains("<b>spoof</b>"));
302
303        // ...and the same through the tooltip sink.
304        let mut o2 = opts();
305        o2.tooltip_format = Some("{or_label}".into());
306        let out2 = render(
307            &sample_outcome(snap.clone()),
308            &snap,
309            &Theme::default(),
310            &o2,
311            Utc::now(),
312        );
313        assert!(out2.tooltip.contains("A &amp; B"));
314        assert!(!out2.tooltip.contains("<b>spoof</b>"));
315    }
316
317    #[test]
318    fn default_render_contains_balance_and_today_usage() {
319        let snap = sample_snap();
320        let outcome = sample_outcome(snap.clone());
321        let theme = Theme::default();
322        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
323        assert!(out.text.contains("$74.50"));
324        assert!(out.text.contains("$1.00"));
325    }
326
327    #[test]
328    fn tooltip_includes_balance_usage_tier() {
329        let snap = sample_snap();
330        let outcome = sample_outcome(snap.clone());
331        let theme = Theme::default();
332        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
333        assert!(out.tooltip.contains("Balance"));
334        assert!(out.tooltip.contains("$25.50 of $100.00 used"));
335        assert!(out.tooltip.contains("paid tier"));
336        assert!(out.tooltip.contains("Per-key limit"));
337        assert!(out.tooltip.contains("$24.50 of $50.00"));
338    }
339
340    #[test]
341    fn free_tier_label() {
342        let mut snap = sample_snap();
343        snap.is_free_tier = true;
344        let outcome = sample_outcome(snap.clone());
345        let theme = Theme::default();
346        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
347        assert!(out.tooltip.contains("free tier"));
348    }
349
350    #[test]
351    fn stale_appends_pause() {
352        let snap = sample_snap();
353        let mut outcome = sample_outcome(snap.clone());
354        outcome.stale = true;
355        let theme = Theme::default();
356        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
357        assert!(out.text.contains("⏸"));
358    }
359
360    #[test]
361    fn custom_tooltip_uses_placeholders() {
362        let snap = sample_snap();
363        let outcome = sample_outcome(snap.clone());
364        let theme = Theme::default();
365        let mut o = opts();
366        o.tooltip_format = Some("bal: {or_balance} | mtd: {or_used_month}".into());
367        let out = render(&outcome, &snap, &theme, &o, Utc::now());
368        assert_eq!(out.tooltip, "bal: $74.50 | mtd: $25.50");
369    }
370
371    #[test]
372    fn severity_keys_on_consumed_pct() {
373        let mut snap = sample_snap();
374        snap.total_usage = 92.0; // 92% consumed → critical
375        assert_eq!(severity(&snap), PaceSeverity::Critical);
376        snap.total_usage = 60.0; // mid
377        assert_eq!(severity(&snap), PaceSeverity::Mid);
378    }
379}