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
85    let format_clean = format.replace("${", "{");
86    let mut text = substitute(&format_clean, &values);
87    if outcome.stale {
88        text.push_str(" ⏸");
89    }
90    let wrapper_color = severity_color(severity(snap), theme).to_string();
91    let icon_prefix = match opts.icon.as_deref() {
92        Some(ic) if !ic.is_empty() => format!("{ic} "),
93        _ => String::new(),
94    };
95    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
96
97    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
98        substitute(&fmt.replace("${", "{"), &values)
99    } else {
100        render_tooltip(outcome, snap, theme, now)
101    };
102
103    WaybarOutput {
104        text: bar_text,
105        tooltip,
106        class,
107    }
108}
109
110fn or_balance_bar(snap: &OpenRouterSnapshot, theme: &Theme) -> String {
111    let pct = snap.consumed_pct();
112    let color = severity_color(severity_for(pct), theme);
113    pango::progress_bar(pct, color, theme, None)
114}
115
116/// OpenRouter severity is keyed on consumed-percentage (low credit = critical).
117pub fn severity(snap: &OpenRouterSnapshot) -> PaceSeverity {
118    severity_for(snap.consumed_pct())
119}
120
121fn render_tooltip(
122    outcome: &VendorOutcome,
123    snap: &OpenRouterSnapshot,
124    theme: &Theme,
125    now: DateTime<Utc>,
126) -> String {
127    let blue = &theme.blue;
128    let dim = &theme.dim;
129    let fg = &theme.fg;
130
131    let color = severity_color(severity(snap), theme);
132    let bar = or_balance_bar(snap, theme);
133
134    let mut lines: Vec<TooltipLine> = Vec::new();
135    lines.push(TooltipLine::Center(format!(
136        "<span font_weight='bold' foreground='{blue}'>{label}</span>",
137        label = escape(&snap.label)
138    )));
139    lines.push(TooltipLine::Sep);
140    lines.push(TooltipLine::Body("".into()));
141
142    lines.push(TooltipLine::Body(format!(
143        " <span foreground='{fg}'>  󰢗  Balance</span>"
144    )));
145    lines.push(TooltipLine::Body(format!(
146        "   {bar}  <span font_weight='bold' foreground='{color}'>{bal}</span>",
147        bal = escape(&format_money(snap.balance()))
148    )));
149    lines.push(TooltipLine::Body(format!(
150        " <span foreground='{dim}'>  {used} of {total} used ({pct}%)</span>",
151        used = escape(&format_money(snap.total_usage)),
152        total = escape(&format_money(snap.total_credits)),
153        pct = snap.consumed_pct()
154    )));
155
156    lines.push(TooltipLine::Body("".into()));
157    lines.push(TooltipLine::Body(format!(
158        " <span foreground='{fg}'>  󰸘  Usage</span>"
159    )));
160    lines.push(TooltipLine::Body(format!(
161        " <span foreground='{dim}'>     today {today} · week {week} · month {month}</span>",
162        today = escape(&format_money(snap.usage_daily)),
163        week = escape(&format_money(snap.usage_weekly)),
164        month = escape(&format_money(snap.usage_monthly))
165    )));
166
167    if let Some(limit) = snap.limit {
168        let rem = snap.limit_remaining.unwrap_or(0.0);
169        lines.push(TooltipLine::Body("".into()));
170        lines.push(TooltipLine::Body(format!(
171            " <span foreground='{fg}'>  󱁻  Per-key limit</span>"
172        )));
173        lines.push(TooltipLine::Body(format!(
174            " <span foreground='{dim}'>     {rem} of {tot} remaining</span>",
175            rem = escape(&format_money(rem)),
176            tot = escape(&format_money(limit))
177        )));
178    }
179
180    let tier_label = if snap.is_free_tier {
181        "free tier"
182    } else {
183        "paid tier"
184    };
185    lines.push(TooltipLine::Body("".into()));
186    lines.push(TooltipLine::Body(format!(
187        " <span foreground='{dim}'>  󰓹  {tier_label}</span>"
188    )));
189
190    if let Some((code, msg)) = outcome.last_error.as_ref()
191        && *code != 0
192    {
193        let (icon, ecolor) = if *code >= 500 {
194            ("󰅚", theme.red.as_str())
195        } else {
196            ("󰀪", theme.orange.as_str())
197        };
198        lines.push(TooltipLine::Body("".into()));
199        lines.push(TooltipLine::Sep);
200        lines.push(TooltipLine::Body(format!(
201            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
202        )));
203        lines.push(TooltipLine::Body(format!(
204            "     <span foreground='{dim}'>{}</span>",
205            escape(msg)
206        )));
207    }
208
209    let updated = updated_at_hm(now, outcome.cache_age);
210    lines.push(TooltipLine::Body("".into()));
211    lines.push(TooltipLine::Sep);
212    lines.push(TooltipLine::Body(format!(
213        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
214    )));
215
216    render_bordered(&lines, theme)
217}
218
219impl From<FetchOutcome> for VendorOutcome {
220    fn from(o: FetchOutcome) -> Self {
221        Self {
222            snapshot: crate::usage::VendorSnapshot::Openrouter(o.snapshot),
223            stale: o.stale,
224            last_error: o.last_error,
225            cache_age: o.cache_age,
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::usage::OpenRouterSnapshot;
234
235    fn sample_snap() -> OpenRouterSnapshot {
236        OpenRouterSnapshot {
237            label: "OpenRouter — prod".into(),
238            total_credits: 100.0,
239            total_usage: 25.5,
240            usage_daily: 1.0,
241            usage_weekly: 7.0,
242            usage_monthly: 25.5,
243            is_free_tier: false,
244            limit: Some(50.0),
245            limit_remaining: Some(24.5),
246        }
247    }
248
249    fn sample_outcome(snap: OpenRouterSnapshot) -> VendorOutcome {
250        VendorOutcome {
251            snapshot: crate::usage::VendorSnapshot::Openrouter(snap),
252            stale: false,
253            last_error: None,
254            cache_age: Some(std::time::Duration::from_secs(15)),
255        }
256    }
257
258    fn opts() -> RenderOpts {
259        RenderOpts {
260            format: None,
261            tooltip_format: None,
262            icon: None,
263            pace_tolerance: 5,
264            format_pace_color: false,
265            tooltip_pace_pts: false,
266        }
267    }
268
269    #[test]
270    fn default_render_contains_balance_and_today_usage() {
271        let snap = sample_snap();
272        let outcome = sample_outcome(snap.clone());
273        let theme = Theme::default();
274        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
275        assert!(out.text.contains("$74.50"));
276        assert!(out.text.contains("$1.00"));
277    }
278
279    #[test]
280    fn tooltip_includes_balance_usage_tier() {
281        let snap = sample_snap();
282        let outcome = sample_outcome(snap.clone());
283        let theme = Theme::default();
284        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
285        assert!(out.tooltip.contains("Balance"));
286        assert!(out.tooltip.contains("$25.50 of $100.00 used"));
287        assert!(out.tooltip.contains("paid tier"));
288        assert!(out.tooltip.contains("Per-key limit"));
289        assert!(out.tooltip.contains("$24.50 of $50.00"));
290    }
291
292    #[test]
293    fn free_tier_label() {
294        let mut snap = sample_snap();
295        snap.is_free_tier = true;
296        let outcome = sample_outcome(snap.clone());
297        let theme = Theme::default();
298        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
299        assert!(out.tooltip.contains("free tier"));
300    }
301
302    #[test]
303    fn stale_appends_pause() {
304        let snap = sample_snap();
305        let mut outcome = sample_outcome(snap.clone());
306        outcome.stale = true;
307        let theme = Theme::default();
308        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
309        assert!(out.text.contains("⏸"));
310    }
311
312    #[test]
313    fn custom_tooltip_uses_placeholders() {
314        let snap = sample_snap();
315        let outcome = sample_outcome(snap.clone());
316        let theme = Theme::default();
317        let mut o = opts();
318        o.tooltip_format = Some("bal: {or_balance} | mtd: {or_used_month}".into());
319        let out = render(&outcome, &snap, &theme, &o, Utc::now());
320        assert_eq!(out.tooltip, "bal: $74.50 | mtd: $25.50");
321    }
322
323    #[test]
324    fn severity_keys_on_consumed_pct() {
325        let mut snap = sample_snap();
326        snap.total_usage = 92.0; // 92% consumed → critical
327        assert_eq!(severity(&snap), PaceSeverity::Critical);
328        snap.total_usage = 60.0; // mid
329        assert_eq!(severity(&snap), PaceSeverity::Mid);
330    }
331}