Skip to main content

ai_usagebar/orcarouter/
vendor.rs

1//! OrcaRouter renderer — bar text + bordered Pango tooltip. A balance card
2//! like OpenRouter's (spend / limit / remaining), spend-only when the key is
3//! unlimited, with an optional key-expiry countdown.
4
5use std::collections::HashMap;
6
7use chrono::{DateTime, Utc};
8
9use crate::countdown;
10use crate::format::{placeholders, substitute, updated_at_hm, usd};
11use crate::pacing::PaceSeverity;
12use crate::pango::{self, color_span, escape, severity_color};
13use crate::theme::Theme;
14use crate::tooltip::{Line as TooltipLine, render_bordered};
15use crate::usage::OrcaRouterSnapshot;
16use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
17use crate::waybar::{Class, WaybarOutput};
18
19use super::fetch::FetchOutcome;
20
21pub const DEFAULT_FORMAT: &str = "{orc_remaining}";
22/// Default bar format for an unlimited (sentinel) key: there is no remaining
23/// figure, so the card is spend-only — the same treatment a missing monthly
24/// limit gets.
25pub const SPEND_ONLY_FORMAT: &str = "{orc_spend} spent";
26
27/// Build the placeholder map for the OrcaRouter snapshot.
28pub fn build_placeholders(
29    snap: &OrcaRouterSnapshot,
30    now: DateTime<Utc>,
31) -> HashMap<&'static str, String> {
32    let consumed_pct = snap.consumed_pct();
33    placeholders(vec![
34        ("icon", VendorId::OrcaRouter.bar_icon().to_string()),
35        (
36            "vendor_short",
37            VendorId::OrcaRouter.short_name().to_string(),
38        ),
39        // Cross-vendor aliases — with a limit the session concept maps to
40        // consumed %; an unlimited key has no percentage anybody can vouch
41        // for, and there is no rate-limit window to reset.
42        (
43            "session_pct",
44            consumed_pct
45                .map(|p| p.to_string())
46                .unwrap_or_else(|| "—".into()),
47        ),
48        ("session_reset", "—".to_string()),
49        (
50            "weekly_pct",
51            consumed_pct
52                .map(|p| p.to_string())
53                .unwrap_or_else(|| "—".into()),
54        ),
55        ("weekly_reset", "—".to_string()),
56        ("plan", "OrcaRouter".to_string()),
57        ("orc_spend", usd(snap.spent_usd())),
58        (
59            "orc_limit",
60            snap.limit_usd()
61                .map(usd)
62                .unwrap_or_else(|| "unlimited".into()),
63        ),
64        (
65            "orc_remaining",
66            snap.remaining_usd()
67                .map(usd)
68                .unwrap_or_else(|| "unlimited".into()),
69        ),
70        (
71            "orc_consumed_pct",
72            consumed_pct
73                .map(|p| p.to_string())
74                .unwrap_or_else(|| "—".into()),
75        ),
76        ("orc_expires", countdown::format(snap.access_until, now)),
77    ])
78}
79
80/// Severity keys on the remaining credit (the actionable number, mirroring
81/// the balance vendors); an unlimited key has no remaining figure, so it
82/// stays calm — the same treatment a missing monthly limit gets.
83pub fn severity(snap: &OrcaRouterSnapshot) -> PaceSeverity {
84    match snap.remaining_usd() {
85        Some(remaining) => crate::pango::balance_severity(remaining, "USD"),
86        None => PaceSeverity::Low,
87    }
88}
89
90/// Compose the full Waybar output for an OrcaRouter snapshot.
91pub fn render(
92    outcome: &VendorOutcome,
93    snap: &OrcaRouterSnapshot,
94    theme: &Theme,
95    opts: &RenderOpts,
96    now: DateTime<Utc>,
97) -> WaybarOutput {
98    let class = Class::from(severity(snap));
99    let format = opts.format.clone().unwrap_or_else(|| {
100        if snap.limit_cents.is_some() {
101            DEFAULT_FORMAT.to_string()
102        } else {
103            SPEND_ONLY_FORMAT.to_string()
104        }
105    });
106    let mut values = build_placeholders(snap, now);
107    values.insert("orc_bar", orc_bar(snap, theme));
108
109    let mut text = substitute(&format, &values);
110    if outcome.stale {
111        text.push_str(" ⏸");
112    }
113
114    let wrapper_color = severity_color(severity(snap), theme).to_string();
115    let icon_prefix = match opts.icon.as_deref() {
116        Some(ic) if !ic.is_empty() => format!("{ic} "),
117        _ => String::new(),
118    };
119    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
120
121    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
122        substitute(fmt, &values)
123    } else {
124        render_tooltip(outcome, snap, theme, now)
125    };
126
127    WaybarOutput {
128        text: bar_text,
129        tooltip,
130        class,
131    }
132}
133
134fn orc_bar(snap: &OrcaRouterSnapshot, theme: &Theme) -> String {
135    let color = severity_color(severity(snap), theme);
136    pango::progress_bar(snap.consumed_pct().unwrap_or(0), color, theme, None)
137}
138
139fn render_tooltip(
140    outcome: &VendorOutcome,
141    snap: &OrcaRouterSnapshot,
142    theme: &Theme,
143    now: DateTime<Utc>,
144) -> String {
145    let blue = &theme.blue;
146    let dim = &theme.dim;
147    let fg = &theme.fg;
148
149    let color = severity_color(severity(snap), theme);
150
151    let mut lines: Vec<TooltipLine> = Vec::new();
152    lines.push(TooltipLine::Center(format!(
153        "<span font_weight='bold' foreground='{blue}'>OrcaRouter</span>"
154    )));
155    lines.push(TooltipLine::Sep);
156    lines.push(TooltipLine::Body("".into()));
157
158    lines.push(TooltipLine::Body(format!(
159        " <span foreground='{fg}'>  󰢗  Credit</span>"
160    )));
161    match snap.remaining_usd() {
162        Some(remaining) => {
163            lines.push(TooltipLine::Body(format!(
164                "   {bar}  <span font_weight='bold' foreground='{color}'>{rem}</span>",
165                bar = orc_bar(snap, theme),
166                rem = escape(&usd(remaining))
167            )));
168            lines.push(TooltipLine::Body(format!(
169                " <span foreground='{dim}'>  {spent} of {limit} used ({pct}%)</span>",
170                spent = escape(&usd(snap.spent_usd())),
171                limit = escape(&usd(snap.limit_usd().unwrap_or_default())),
172                pct = snap.consumed_pct().unwrap_or(0)
173            )));
174        }
175        None => {
176            // Unlimited key: no limit, no percentage — spend-only.
177            lines.push(TooltipLine::Body(format!(
178                "   <span font_weight='bold' foreground='{color}'>{spent} spent</span>",
179                spent = escape(&usd(snap.spent_usd()))
180            )));
181            lines.push(TooltipLine::Body(format!(
182                " <span foreground='{dim}'>  no credit limit on this key</span>"
183            )));
184        }
185    }
186
187    if let Some(access_until) = snap.access_until {
188        lines.push(TooltipLine::Body("".into()));
189        lines.push(TooltipLine::Body(format!(
190            " <span foreground='{dim}'>  󰅐  Key expires in {left}</span>",
191            left = escape(&countdown::format(Some(access_until), now))
192        )));
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        o.map(crate::usage::VendorSnapshot::OrcaRouter)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::usage::OrcaRouterSnapshot;
234
235    fn sample_snap() -> OrcaRouterSnapshot {
236        OrcaRouterSnapshot {
237            spent_cents: 275,
238            limit_cents: Some(1250),
239            access_until: None,
240        }
241    }
242
243    fn sample_outcome(snap: OrcaRouterSnapshot) -> VendorOutcome {
244        VendorOutcome {
245            snapshot: crate::usage::VendorSnapshot::OrcaRouter(snap),
246            stale: false,
247            last_error: None,
248            cache_age: Some(std::time::Duration::from_secs(15)),
249        }
250    }
251
252    fn opts() -> RenderOpts {
253        RenderOpts {
254            format: None,
255            tooltip_format: None,
256            icon: None,
257            pace_tolerance: 5,
258            format_pace_color: false,
259            tooltip_pace_pts: false,
260        }
261    }
262
263    #[test]
264    fn default_render_shows_remaining() {
265        let snap = sample_snap();
266        let outcome = sample_outcome(snap.clone());
267        let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
268        assert!(out.text.contains("$9.75"), "{}", out.text);
269    }
270
271    /// Cents, never dollars: wire `total_usage: 275` must render as $2.75,
272    /// not $275.00.
273    #[test]
274    fn spend_is_rendered_in_dollars_not_cents() {
275        let snap = sample_snap();
276        let outcome = sample_outcome(snap.clone());
277        let mut o = opts();
278        o.format = Some("{orc_spend}".into());
279        let out = render(&outcome, &snap, &Theme::default(), &o, Utc::now());
280        assert!(out.text.contains("$2.75"), "{}", out.text);
281        assert!(!out.text.contains("$275"), "{}", out.text);
282    }
283
284    /// The unlimited sentinel's whole point: no limit row, no $100M figure,
285    /// spend-only — like the codebase treats a missing monthly limit.
286    #[test]
287    fn unlimited_key_renders_spend_only_not_a_hundred_million_wallet() {
288        let snap = OrcaRouterSnapshot {
289            spent_cents: 275,
290            limit_cents: None,
291            access_until: None,
292        };
293        let outcome = sample_outcome(snap.clone());
294        let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
295        assert!(out.text.contains("$2.75"), "{}", out.text);
296        assert!(!out.text.contains("100,000,000"), "{}", out.text);
297        assert!(!out.text.contains("$100000000"), "{}", out.text);
298        assert!(!out.text.contains("$100,000,000.00"), "{}", out.text);
299        assert_eq!(severity(&snap), PaceSeverity::Low);
300
301        let mut o = opts();
302        o.format = Some("{orc_limit}|{orc_remaining}".into());
303        let out = render(&outcome, &snap, &Theme::default(), &o, Utc::now());
304        assert!(out.text.contains("unlimited|unlimited"), "{}", out.text);
305
306        let tooltip = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now()).tooltip;
307        assert!(tooltip.contains("no credit limit"), "{tooltip}");
308        assert!(!tooltip.contains("100,000,000"), "{tooltip}");
309    }
310
311    #[test]
312    fn tooltip_shows_spend_limit_remaining_and_expiry() {
313        let mut snap = sample_snap();
314        snap.access_until = Some(Utc::now() + chrono::Duration::days(30));
315        let outcome = sample_outcome(snap.clone());
316        let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
317        assert!(out.tooltip.contains("$2.75"), "{}", out.tooltip);
318        assert!(out.tooltip.contains("of $12.50 used"), "{}", out.tooltip);
319        assert!(out.tooltip.contains("$9.75"), "{}", out.tooltip);
320        assert!(out.tooltip.contains("expires"), "{}", out.tooltip);
321    }
322
323    /// `access_until: 0` (no expiry) must not paint an expiry row at all.
324    #[test]
325    fn access_until_zero_means_no_expiry_row() {
326        let snap = sample_snap();
327        let outcome = sample_outcome(snap.clone());
328        let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
329        assert!(!out.tooltip.contains("expires"), "{}", out.tooltip);
330        let values = build_placeholders(&snap, Utc::now());
331        assert_eq!(values["orc_expires"], "—");
332    }
333
334    #[test]
335    fn custom_tooltip_uses_placeholders() {
336        let snap = sample_snap();
337        let outcome = sample_outcome(snap.clone());
338        let mut o = opts();
339        o.tooltip_format = Some("left: {orc_remaining} of {orc_limit}".into());
340        let out = render(&outcome, &snap, &Theme::default(), &o, Utc::now());
341        assert_eq!(out.tooltip, "left: $9.75 of $12.50");
342    }
343
344    #[test]
345    fn stale_appends_pause() {
346        let snap = sample_snap();
347        let mut outcome = sample_outcome(snap.clone());
348        outcome.stale = true;
349        let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
350        assert!(out.text.contains("⏸"));
351    }
352
353    /// Spend past the limit is a debt: signed remaining, critical severity —
354    /// the OpenRouter overrun treatment.
355    #[test]
356    fn overrun_is_a_debt_not_zero() {
357        let snap = OrcaRouterSnapshot {
358            spent_cents: 1300,
359            limit_cents: Some(1250),
360            access_until: None,
361        };
362        let outcome = sample_outcome(snap.clone());
363        let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
364        assert!(out.text.contains("-$0.50"), "{}", out.text);
365        assert_eq!(severity(&snap), PaceSeverity::Critical);
366    }
367
368    #[test]
369    fn severity_scales_with_remaining() {
370        assert_eq!(
371            severity(&OrcaRouterSnapshot {
372                spent_cents: 11950,
373                limit_cents: Some(12000),
374                access_until: None
375            }),
376            PaceSeverity::Critical
377        );
378        assert_eq!(
379            severity(&OrcaRouterSnapshot {
380                spent_cents: 11600,
381                limit_cents: Some(12000),
382                access_until: None
383            }),
384            PaceSeverity::High
385        );
386        assert_eq!(
387            severity(&OrcaRouterSnapshot {
388                spent_cents: 0,
389                limit_cents: Some(12000),
390                access_until: None
391            }),
392            PaceSeverity::Low
393        );
394    }
395}