Skip to main content

ai_usagebar/
vendor.rs

1//! Shared vendor IDs and renderer/fetcher structs used by the widget and TUI.
2//!
3//! Snapshots remain a discriminated `VendorSnapshot` enum because the vendors
4//! have genuinely different shapes — see `usage.rs`.
5
6use std::collections::BTreeSet;
7use std::sync::{Mutex, OnceLock};
8use std::time::Duration;
9
10use clap::ValueEnum;
11
12use crate::usage::VendorSnapshot;
13use crate::widget::cli::Cli;
14
15/// Outer reqwest client timeout shared by widget and TUI entry points.
16/// Vendor fetchers still apply their own tighter per-request timeouts.
17pub const HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
18
19/// Upper bound on a vendor response body. Every one of these endpoints returns
20/// a small JSON document — the largest observed is a few kilobytes — so this is
21/// generous by three orders of magnitude while still bounding the damage from a
22/// misbehaving proxy or a hijacked endpoint.
23pub const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
24
25/// Credential-bearing environment variables owned by ai-usagebar vendors.
26/// Subprocesses receive only the entries that belong to their own provider.
27pub(crate) const VENDOR_SECRET_ENV_VARS: &[&str] = &[
28    "ZAI_API_KEY",
29    "OPENROUTER_API_KEY",
30    "DEEPSEEK_API_KEY",
31    "KIMI_API_KEY",
32    "KILO_API_KEY",
33    "NOVITA_API_KEY",
34    "MINIMAX_API_KEY",
35    "MOONSHOT_API_KEY",
36    "XAI_MANAGEMENT_KEY",
37    "ANTHROPIC_ADMIN_KEY",
38    "XAI_API_KEY",
39    "GROK_API_KEY",
40    "OPENCODE_GO_API_KEY",
41    "COMMANDCODE_API_KEY",
42    "GITHUB_COPILOT_TOKEN",
43    "GH_TOKEN",
44    "GITHUB_TOKEN",
45    "OLLAMA_API_KEY",
46];
47
48/// Env var names a `[[custom]]` provider reads its token from. They are not
49/// known until the config is parsed, so they cannot sit in the static list
50/// above, but they are exactly as secret as `DEEPSEEK_API_KEY` and must be
51/// scrubbed from every subprocess the same way.
52fn registered_secret_env_vars() -> &'static Mutex<BTreeSet<&'static str>> {
53    static REGISTERED: OnceLock<Mutex<BTreeSet<&'static str>>> = OnceLock::new();
54    REGISTERED.get_or_init(|| Mutex::new(BTreeSet::new()))
55}
56
57/// Extra env var names (custom providers' `api_key_env`) that must be
58/// scrubbed from every child process. Additive and idempotent; names that are
59/// not valid env var names, or already in [`VENDOR_SECRET_ENV_VARS`], are
60/// ignored.
61///
62/// A name is interned once, on first registration, so the removal list keeps
63/// its `&'static str` element type and the three call sites and their tests
64/// stay untouched. The set is bounded by the user's config, and re-loading
65/// the same config registers nothing new, so the leak is a handful of short
66/// strings for the life of the process.
67pub fn register_secret_env_vars(names: &[String]) {
68    let mut registered = registered_secret_env_vars()
69        .lock()
70        .unwrap_or_else(|poisoned| poisoned.into_inner());
71    for name in names {
72        if !crate::config::is_valid_env_var_name(name)
73            || VENDOR_SECRET_ENV_VARS.contains(&name.as_str())
74            || registered.contains(name.as_str())
75        {
76            continue;
77        }
78        registered.insert(Box::leak(name.clone().into_boxed_str()));
79    }
80}
81
82pub(crate) fn vendor_secret_env_vars_to_remove(keep: &[&str]) -> Vec<&'static str> {
83    let registered = registered_secret_env_vars()
84        .lock()
85        .unwrap_or_else(|poisoned| poisoned.into_inner());
86    VENDOR_SECRET_ENV_VARS
87        .iter()
88        .copied()
89        .chain(registered.iter().copied())
90        .filter(|var| !keep.contains(var))
91        .collect()
92}
93
94/// Follow ordinary vendor redirects without forwarding non-standard API-key
95/// headers to a different origin. Reqwest strips `Authorization` on sensitive
96/// redirects, but vendors also use headers such as `x-api-key`, which are not
97/// covered by that built-in list.
98pub fn same_origin_redirect_policy() -> reqwest::redirect::Policy {
99    reqwest::redirect::Policy::custom(|attempt| {
100        if attempt.previous().len() >= 10 {
101            return attempt.error("too many redirects");
102        }
103        let Some(origin) = attempt.previous().first() else {
104            return attempt.stop();
105        };
106        let target = attempt.url();
107        if target.scheme() == origin.scheme()
108            && target.host_str() == origin.host_str()
109            && target.port_or_known_default() == origin.port_or_known_default()
110        {
111            attempt.follow()
112        } else {
113            attempt.stop()
114        }
115    })
116}
117
118/// Read a response body with an upper bound.
119///
120/// Every vendor buffered the whole body with `resp.bytes()` *before* anything
121/// validated it. The widget is re-executed by Waybar every 60s, so an endpoint
122/// answering with an unbounded stream had a free hand at the machine's memory.
123/// `Content-Length` is checked first when present, then the body is read in
124/// chunks so a lying or absent length cannot get past the cap either.
125pub async fn read_body_capped(
126    mut resp: reqwest::Response,
127    max: usize,
128) -> crate::error::Result<Vec<u8>> {
129    let too_big = |n: u64| {
130        crate::error::AppError::Schema(format!(
131            "response body exceeds the {max}-byte limit ({n} bytes); refusing to buffer it"
132        ))
133    };
134    if let Some(len) = resp.content_length()
135        && len > max as u64
136    {
137        return Err(too_big(len));
138    }
139    let mut buf: Vec<u8> = Vec::new();
140    while let Some(chunk) = resp.chunk().await? {
141        if chunk.len() > max.saturating_sub(buf.len()) {
142            return Err(too_big(buf.len().saturating_add(chunk.len()) as u64));
143        }
144        buf.extend_from_slice(&chunk);
145    }
146    Ok(buf)
147}
148
149/// Stable enum used by `--vendor` and in config files.
150#[derive(
151    Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize,
152)]
153#[serde(rename_all = "lowercase")]
154pub enum VendorId {
155    Anthropic,
156    #[serde(rename = "anthropic_api")]
157    AnthropicApi,
158    Openai,
159    Copilot,
160    Zai,
161    Openrouter,
162    Deepseek,
163    Kimi,
164    Kilo,
165    Novita,
166    Moonshot,
167    Grok,
168    Supergrok,
169    Antigravity,
170    Cursor,
171    Minimax,
172    Kiro,
173    #[serde(rename = "nous")]
174    NousResearch,
175    #[serde(rename = "opencode-go")]
176    OpenCodeGo,
177    #[serde(rename = "commandcode")]
178    CommandCode,
179    Ollama,
180}
181
182/// How a provider authenticates. Drives what a frontend offers a provider that
183/// is not usable yet: a command to run, a variable to set, or an app to sign
184/// in to.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
186#[serde(rename_all = "lowercase")]
187pub enum AuthKind {
188    /// An interactive login writes a credential file. `login_command` runs it.
189    Oauth,
190    /// An API key, from the environment or an inline `api_key` in config.
191    ApiKey,
192    /// No credential of its own — a local product's session or state file is
193    /// the login, and there is nothing for the user to paste.
194    Local,
195}
196
197impl AuthKind {
198    pub const fn as_str(self) -> &'static str {
199        match self {
200            AuthKind::Oauth => "oauth",
201            AuthKind::ApiKey => "apikey",
202            AuthKind::Local => "local",
203        }
204    }
205}
206
207impl VendorId {
208    pub fn slug(self) -> &'static str {
209        match self {
210            VendorId::Anthropic => "anthropic",
211            VendorId::AnthropicApi => "anthropic_api",
212            VendorId::Openai => "openai",
213            VendorId::Copilot => "copilot",
214            VendorId::Zai => "zai",
215            VendorId::Openrouter => "openrouter",
216            VendorId::Deepseek => "deepseek",
217            VendorId::Kimi => "kimi",
218            VendorId::Kilo => "kilo",
219            VendorId::Novita => "novita",
220            VendorId::Moonshot => "moonshot",
221            VendorId::Grok => "grok",
222            VendorId::Supergrok => "supergrok",
223            VendorId::Antigravity => "antigravity",
224            VendorId::Cursor => "cursor",
225            VendorId::Minimax => "minimax",
226            VendorId::Kiro => "kiro",
227            VendorId::NousResearch => "nous",
228            VendorId::OpenCodeGo => "opencode-go",
229            VendorId::CommandCode => "commandcode",
230            VendorId::Ollama => "ollama",
231        }
232    }
233
234    /// Canonical human-readable name for shared reports and compact UI labels.
235    /// Platform frontends may add context (for example, "GLM (Z.AI)" in a
236    /// wide TUI tab), but should not carry their own full vendor-name table.
237    pub fn display_name(self) -> &'static str {
238        match self {
239            VendorId::Anthropic => "Claude",
240            VendorId::AnthropicApi => "Anthropic API",
241            VendorId::Openai => "Codex",
242            VendorId::Copilot => "GitHub Copilot",
243            VendorId::Zai => "Z.AI",
244            VendorId::Openrouter => "OpenRouter",
245            VendorId::Deepseek => "DeepSeek",
246            VendorId::Kimi => "Kimi",
247            VendorId::Kilo => "Kilo",
248            VendorId::Novita => "Novita",
249            VendorId::Moonshot => "Moonshot",
250            VendorId::Grok => "Grok",
251            VendorId::Supergrok => "SuperGrok",
252            VendorId::Antigravity => "Antigravity",
253            VendorId::Cursor => "Cursor",
254            VendorId::Minimax => "MiniMax",
255            VendorId::Kiro => "Kiro",
256            VendorId::NousResearch => "Nous Research",
257            VendorId::OpenCodeGo => "OpenCode Go",
258            VendorId::CommandCode => "Command Code",
259            VendorId::Ollama => "Ollama Cloud",
260        }
261    }
262
263    /// Glyph for a compact bar chip. Same role as [`Self::short_name`]: the
264    /// Omarchy top bar (and any other frontend) takes it from `usage --json`
265    /// rather than keeping its own provider-icon table.
266    pub const fn bar_icon(self) -> &'static str {
267        match self {
268            VendorId::Anthropic => "󰚩",
269            VendorId::AnthropicApi => "󰢗",
270            VendorId::Openai => "󱢆",
271            VendorId::Copilot => "󰊤",
272            VendorId::Zai => VendorId::Zai.short_name(),
273            VendorId::Openrouter => "󱙺",
274            VendorId::Deepseek => "󰧑",
275            VendorId::Kimi => VendorId::Kimi.short_name(),
276            VendorId::Kilo => "󰭟",
277            VendorId::Novita => "󰄔",
278            VendorId::Moonshot => VendorId::Moonshot.short_name(),
279            VendorId::Grok | VendorId::Supergrok => "󰇷",
280            VendorId::Antigravity => VendorId::Antigravity.short_name(),
281            VendorId::Cursor => "❯",
282            VendorId::Minimax => VendorId::Minimax.short_name(),
283            VendorId::Kiro => "◆",
284            VendorId::NousResearch => VendorId::NousResearch.short_name(),
285            VendorId::OpenCodeGo => VendorId::OpenCodeGo.short_name(),
286            VendorId::CommandCode => VendorId::CommandCode.short_name(),
287            // No distinct Nerd Font mark for Ollama Cloud; the `oll` short
288            // name is unique by construction and cannot render as tofu.
289            VendorId::Ollama => VendorId::Ollama.short_name(),
290        }
291    }
292
293    /// Compact three-letter code for the bar. This is the single source for
294    /// `{vendor_short}` in every renderer, the `usage --json` `short_name`
295    /// field, and any frontend that wants a Waybar-style provider tag; a
296    /// second copy in a placeholder map or a QML file is how the table forks.
297    pub const fn short_name(self) -> &'static str {
298        match self {
299            VendorId::Anthropic => "cld",
300            VendorId::AnthropicApi => "aac",
301            VendorId::Openai => "gpt",
302            VendorId::Copilot => "ghc",
303            VendorId::Zai => "zai",
304            VendorId::Openrouter => "opr",
305            VendorId::Deepseek => "dsk",
306            VendorId::Kimi => "kmi",
307            VendorId::Kilo => "klo",
308            VendorId::Novita => "nvt",
309            VendorId::Moonshot => "msh",
310            VendorId::Grok => "grk",
311            VendorId::Supergrok => "sgk",
312            VendorId::Antigravity => "agy",
313            VendorId::Cursor => "cur",
314            VendorId::Minimax => "mmx",
315            VendorId::Kiro => "kir",
316            VendorId::NousResearch => "nrs",
317            VendorId::OpenCodeGo => "ocg",
318            VendorId::CommandCode => "cmc",
319            VendorId::Ollama => "oll",
320        }
321    }
322
323    /// The `config.toml` table this vendor's settings live in — the `Config`
324    /// field name, or its `#[serde(rename)]` where one applies. This is the
325    /// single source for every writer that edits a vendor section by name
326    /// (the Settings overlay's `KEY_VENDORS`, `config::enable_vendors_in`), so
327    /// a section can't be spelled one way by the parser and another by a
328    /// writer. A guard test in `config` parses `[<section>] enabled = true` for
329    /// every vendor and checks `is_enabled`.
330    pub const fn config_section(self) -> &'static str {
331        match self {
332            VendorId::Anthropic => "anthropic",
333            VendorId::AnthropicApi => "anthropic_api",
334            VendorId::Openai => "openai",
335            VendorId::Copilot => "copilot",
336            VendorId::Zai => "zai",
337            VendorId::Openrouter => "openrouter",
338            VendorId::Deepseek => "deepseek",
339            VendorId::Kimi => "kimi",
340            VendorId::Kilo => "kilo",
341            VendorId::Novita => "novita",
342            VendorId::Moonshot => "moonshot",
343            VendorId::Grok => "grok",
344            VendorId::Supergrok => "supergrok",
345            VendorId::Antigravity => "antigravity",
346            VendorId::Cursor => "cursor",
347            VendorId::Minimax => "minimax",
348            VendorId::Kiro => "kiro",
349            VendorId::NousResearch => "nous",
350            VendorId::OpenCodeGo => "opencode-go",
351            VendorId::CommandCode => "commandcode",
352            VendorId::Ollama => "ollama",
353        }
354    }
355
356    /// How a provider proves who you are. This is the fact a frontend needs to
357    /// say what an unconfigured provider is still missing, and it is the one
358    /// thing neither `usage --json` nor the config file carries: the report
359    /// lists only *enabled* providers, so the switched-off and the
360    /// never-credentialed are exactly the rows it cannot describe.
361    pub const fn auth_kind(self) -> AuthKind {
362        match self {
363            VendorId::Anthropic
364            | VendorId::Openai
365            | VendorId::Copilot
366            | VendorId::NousResearch
367            | VendorId::CommandCode => AuthKind::Oauth,
368            VendorId::AnthropicApi
369            | VendorId::Zai
370            | VendorId::Openrouter
371            | VendorId::Deepseek
372            | VendorId::Kimi
373            | VendorId::Kilo
374            | VendorId::Novita
375            | VendorId::Moonshot
376            | VendorId::Grok
377            | VendorId::Minimax
378            | VendorId::OpenCodeGo
379            | VendorId::Ollama => AuthKind::ApiKey,
380            // No credential of their own: another local product's session is
381            // the login. Antigravity has no credential file at all (the binary
382            // probes whichever local server answers), Cursor and Kiro read the
383            // IDE's and kiro-cli's own state, and SuperGrok uses the Grok Build
384            // CLI's login.
385            VendorId::Supergrok | VendorId::Antigravity | VendorId::Cursor | VendorId::Kiro => {
386                AuthKind::Local
387            }
388        }
389    }
390
391    /// Default environment variable holding this provider's key, or `""` for a
392    /// provider that has none. This is only the *default*: most key vendors
393    /// accept an `api_key_env` override in config, so a frontend showing the
394    /// variable a user must set wants [`Config::api_key_env_for`], not this.
395    pub const fn api_key_env(self) -> &'static str {
396        match self {
397            VendorId::AnthropicApi => "ANTHROPIC_ADMIN_KEY",
398            VendorId::Zai => "ZAI_API_KEY",
399            VendorId::Openrouter => "OPENROUTER_API_KEY",
400            VendorId::Deepseek => "DEEPSEEK_API_KEY",
401            VendorId::Kimi => "KIMI_API_KEY",
402            VendorId::Kilo => "KILO_API_KEY",
403            VendorId::Novita => "NOVITA_API_KEY",
404            VendorId::Moonshot => "MOONSHOT_API_KEY",
405            VendorId::Grok => "XAI_MANAGEMENT_KEY",
406            VendorId::Minimax => "MINIMAX_API_KEY",
407            VendorId::OpenCodeGo => "OPENCODE_GO_API_KEY",
408            VendorId::Ollama => "OLLAMA_API_KEY",
409            // OAuth-first, with an environment override for CI and headless
410            // use. Neither name is configurable, so neither has an
411            // `api_key_env` field in its config section.
412            VendorId::Copilot => "GITHUB_COPILOT_TOKEN",
413            VendorId::CommandCode => "COMMANDCODE_API_KEY",
414            VendorId::Anthropic
415            | VendorId::Openai
416            | VendorId::Supergrok
417            | VendorId::Antigravity
418            | VendorId::Cursor
419            | VendorId::Kiro
420            | VendorId::NousResearch => "",
421        }
422    }
423
424    /// Command that signs this provider in, or `""` when signing in happens
425    /// somewhere this cannot name — a desktop app's own window. The strings
426    /// are the ones the vendor modules' own credential errors already print,
427    /// so a status row and a failed fetch tell the user to run the same thing.
428    /// One sentence telling the user how to sign this provider in, for a UI
429    /// that has an error card and no room for a manual.
430    ///
431    /// This is product knowledge, so it lives beside [`Self::login_command`]
432    /// rather than in a frontend table. The Windows popover grew its own copy
433    /// first and it disagreed with this one for five of eight providers before
434    /// it had shipped — the match here is exhaustive, so a new provider cannot
435    /// be added without saying how a person signs into it.
436    pub const fn sign_in_hint(self) -> &'static str {
437        match self {
438            VendorId::Anthropic => "Run `claude` in a terminal, then Refresh.",
439            VendorId::Openai => "Run `codex login` in a terminal, then Refresh.",
440            VendorId::Copilot => "Run `gh auth login` in a terminal, then Refresh.",
441            VendorId::Kiro => "Run `kiro-cli login` in a terminal, then Refresh.",
442            VendorId::Kimi => "Run `kimi` in a terminal, or set an API key.",
443            VendorId::CommandCode => "Run `commandcode` in a terminal, then Refresh.",
444            VendorId::NousResearch => {
445                "Run `ai-usagebar auth nous login` in a terminal, then Refresh."
446            }
447            VendorId::Cursor => "Sign in to the Cursor app, then Refresh.",
448            VendorId::Antigravity => "Open Antigravity or run `agy`, then Refresh.",
449            VendorId::Grok | VendorId::Supergrok => "Sign in with `grok`, then Refresh.",
450            // Key-only providers: there is nothing to log into, only a key to
451            // put in the config. Ollama Cloud's key is minted at
452            // ollama.com/settings/keys; the local `ollama` CLI's Ed25519 key
453            // is a registry credential, not a quota one, and is never read.
454            VendorId::AnthropicApi
455            | VendorId::Zai
456            | VendorId::Openrouter
457            | VendorId::Deepseek
458            | VendorId::Kilo
459            | VendorId::Novita
460            | VendorId::Moonshot
461            | VendorId::Minimax
462            | VendorId::OpenCodeGo
463            | VendorId::Ollama => "Add an API key in Settings, then Refresh.",
464        }
465    }
466
467    pub const fn login_command(self) -> &'static str {
468        match self {
469            VendorId::Anthropic => "claude",
470            VendorId::Openai => "codex login",
471            VendorId::Copilot => "gh auth login",
472            VendorId::CommandCode => "commandcode",
473            VendorId::NousResearch => "ai-usagebar auth nous login",
474            VendorId::Kiro => "kiro-cli login",
475            // Kimi takes a key *or* the Kimi Code CLI's own OAuth login, which
476            // is what a subscriber already has locally.
477            VendorId::Kimi => "kimi",
478            VendorId::AnthropicApi
479            | VendorId::Zai
480            | VendorId::Openrouter
481            | VendorId::Deepseek
482            | VendorId::Kilo
483            | VendorId::Novita
484            | VendorId::Moonshot
485            | VendorId::Grok
486            | VendorId::Supergrok
487            | VendorId::Antigravity
488            | VendorId::Cursor
489            | VendorId::Minimax
490            | VendorId::OpenCodeGo
491            | VendorId::Ollama => "",
492        }
493    }
494
495    pub fn all() -> &'static [VendorId] {
496        &[
497            VendorId::Anthropic,
498            VendorId::AnthropicApi,
499            VendorId::Openai,
500            VendorId::Copilot,
501            VendorId::Zai,
502            VendorId::Openrouter,
503            VendorId::Deepseek,
504            VendorId::Kimi,
505            VendorId::Kilo,
506            VendorId::Novita,
507            VendorId::Moonshot,
508            VendorId::Grok,
509            VendorId::Supergrok,
510            VendorId::Antigravity,
511            VendorId::Cursor,
512            VendorId::Minimax,
513            VendorId::Kiro,
514            VendorId::NousResearch,
515            VendorId::OpenCodeGo,
516            VendorId::CommandCode,
517            VendorId::Ollama,
518        ]
519    }
520}
521
522/// What a vendor returns from a successful fetch — the same
523/// [`Outcome`](crate::outcome::Outcome) every vendor produces, once its own
524/// snapshot type has been widened to [`VendorSnapshot`]. Each vendor gets
525/// there with a single `outcome.map(VendorSnapshot::Whichever)`.
526pub type VendorOutcome = crate::outcome::Outcome<VendorSnapshot>;
527
528/// Options forwarded to renderers from the CLI.
529#[derive(Debug, Clone)]
530pub struct RenderOpts {
531    pub format: Option<String>,
532    pub tooltip_format: Option<String>,
533    pub icon: Option<String>,
534    pub pace_tolerance: u32,
535    pub format_pace_color: bool,
536    pub tooltip_pace_pts: bool,
537}
538
539impl RenderOpts {
540    pub fn from_cli(cli: &Cli) -> Self {
541        Self {
542            format: cli.format.clone(),
543            tooltip_format: cli.tooltip_format.clone(),
544            icon: cli.icon.clone(),
545            pace_tolerance: cli.pace_tolerance,
546            format_pace_color: cli.format_pace_color,
547            tooltip_pace_pts: cli.tooltip_pace_pts,
548        }
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn every_vendor_has_stable_machine_and_display_names() {
558        for vendor in VendorId::all() {
559            assert!(!vendor.slug().is_empty());
560            assert!(!vendor.display_name().is_empty());
561        }
562        assert_eq!(VendorId::Anthropic.slug(), "anthropic");
563        assert_eq!(VendorId::Anthropic.display_name(), "Claude");
564        assert_eq!(VendorId::Openai.display_name(), "Codex");
565        assert_eq!(VendorId::Zai.display_name(), "Z.AI");
566    }
567
568    /// `{vendor_short}` is a documented format placeholder and now also rides
569    /// the `usage --json` report, so a duplicate or a re-typed code would make
570    /// two providers indistinguishable in a bar that shows nothing else.
571    #[test]
572    fn every_vendor_short_name_is_a_unique_three_letter_code() {
573        let mut seen = std::collections::BTreeSet::new();
574        for vendor in VendorId::all() {
575            let short = vendor.short_name();
576            assert_eq!(short.len(), 3, "{} is not three letters", vendor.slug());
577            assert!(
578                short.chars().all(|c| c.is_ascii_lowercase()),
579                "{} is not lowercase ascii",
580                vendor.slug()
581            );
582            assert!(seen.insert(short), "{short} is used by two vendors");
583        }
584        assert_eq!(VendorId::Anthropic.short_name(), "cld");
585        assert_eq!(VendorId::Openai.short_name(), "gpt");
586        assert_eq!(VendorId::Zai.short_name(), "zai");
587        assert_eq!(VendorId::Antigravity.short_name(), "agy");
588    }
589
590    /// The bar can show every provider at once, so a glyph two providers share
591    /// tells the user nothing about which row is which. Grok and SuperGrok are
592    /// the one sanctioned pair — same brand, two products. Providers without a
593    /// distinct Nerd Font mark use their `short_name`, which is unique by
594    /// construction and cannot render as tofu.
595    #[test]
596    fn every_vendor_has_a_bar_icon_and_no_two_share_one() {
597        use std::collections::BTreeMap;
598
599        let mut by_icon: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
600        for vendor in VendorId::all() {
601            assert!(!vendor.bar_icon().is_empty(), "{}", vendor.slug());
602            by_icon
603                .entry(vendor.bar_icon())
604                .or_default()
605                .push(vendor.slug());
606        }
607
608        let shared: Vec<_> = by_icon
609            .iter()
610            .filter(|(_, vendors)| vendors.len() > 1)
611            .filter(|(_, vendors)| vendors.as_slice() != ["grok", "supergrok"])
612            .collect();
613        assert!(
614            shared.is_empty(),
615            "these providers are indistinguishable in a bar that shows them \
616             side by side: {shared:#?}"
617        );
618        assert_eq!(VendorId::Anthropic.bar_icon(), "󰚩");
619        assert_eq!(VendorId::Openai.bar_icon(), "󱢆");
620        assert_eq!(VendorId::Supergrok.bar_icon(), VendorId::Grok.bar_icon());
621        assert_eq!(VendorId::CommandCode.bar_icon(), "cmc");
622    }
623
624    #[test]
625    fn new_vendor_contracts_keep_public_names_and_slugs() {
626        assert_eq!(VendorId::NousResearch.slug(), "nous");
627        assert_eq!(VendorId::NousResearch.display_name(), "Nous Research");
628        assert_eq!(VendorId::OpenCodeGo.slug(), "opencode-go");
629        assert_eq!(VendorId::OpenCodeGo.display_name(), "OpenCode Go");
630        assert_eq!(
631            serde_json::to_value(VendorId::OpenCodeGo).unwrap(),
632            serde_json::json!("opencode-go")
633        );
634    }
635
636    #[test]
637    fn vendor_secret_env_vars_cover_config_defaults() {
638        let configured_defaults = [
639            "ZAI_API_KEY",
640            "OPENROUTER_API_KEY",
641            "DEEPSEEK_API_KEY",
642            "KIMI_API_KEY",
643            "KILO_API_KEY",
644            "NOVITA_API_KEY",
645            "MINIMAX_API_KEY",
646            "MOONSHOT_API_KEY",
647            "XAI_MANAGEMENT_KEY",
648            "ANTHROPIC_ADMIN_KEY",
649            "GITHUB_COPILOT_TOKEN",
650        ];
651        for name in configured_defaults {
652            assert!(VENDOR_SECRET_ENV_VARS.contains(&name), "missing {name}");
653        }
654    }
655
656    #[test]
657    fn vars_to_remove_preserves_only_requested_grok_credentials() {
658        let removed = vendor_secret_env_vars_to_remove(&["XAI_API_KEY", "GROK_API_KEY"]);
659        assert!(!removed.contains(&"XAI_API_KEY"));
660        assert!(!removed.contains(&"GROK_API_KEY"));
661        assert!(removed.contains(&"ANTHROPIC_ADMIN_KEY"));
662        assert!(removed.contains(&"OPENROUTER_API_KEY"));
663        // Counted against the static list: another test in this process may
664        // have registered a custom provider's env var, which belongs here too.
665        let builtins = removed
666            .iter()
667            .filter(|var| VENDOR_SECRET_ENV_VARS.contains(var))
668            .count();
669        assert_eq!(builtins, VENDOR_SECRET_ENV_VARS.len() - 2);
670    }
671
672    #[test]
673    fn a_registered_custom_env_var_is_scrubbed_like_a_builtin_one() {
674        let name = "AI_USAGEBAR_TEST_CUSTOM_TOKEN_7F3A";
675        assert!(!vendor_secret_env_vars_to_remove(&[]).contains(&name));
676
677        register_secret_env_vars(&[name.to_string(), "not a name!".to_string()]);
678        register_secret_env_vars(&[name.to_string()]);
679
680        let removed = vendor_secret_env_vars_to_remove(&[]);
681        assert_eq!(
682            removed.iter().filter(|var| **var == name).count(),
683            1,
684            "registering twice must not list it twice: {removed:?}"
685        );
686        assert!(!removed.contains(&"not a name!"), "{removed:?}");
687        assert!(
688            !vendor_secret_env_vars_to_remove(&[name]).contains(&name),
689            "`keep` applies to registered names too"
690        );
691    }
692
693    #[test]
694    fn copilot_token_is_removed_before_unrelated_subprocesses_launch() {
695        let removed = vendor_secret_env_vars_to_remove(&[]);
696        assert!(removed.contains(&"GITHUB_COPILOT_TOKEN"));
697    }
698
699    #[tokio::test]
700    async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
701        let mut server = mockito::Server::new_async().await;
702        server
703            .mock("GET", "/big")
704            .with_status(200)
705            .with_body("x".repeat(4096))
706            .create_async()
707            .await;
708        server
709            .mock("GET", "/small")
710            .with_status(200)
711            .with_body("hello")
712            .create_async()
713            .await;
714
715        let client = reqwest::Client::new();
716
717        // Over the cap: refused rather than buffered.
718        let resp = client
719            .get(format!("{}/big", server.url()))
720            .send()
721            .await
722            .unwrap();
723        let err = read_body_capped(resp, 1024).await.unwrap_err();
724        assert!(
725            err.to_string().contains("exceeds"),
726            "unexpected error: {err}"
727        );
728
729        // Under the cap: identical to the previous `resp.bytes()` behaviour.
730        let resp = client
731            .get(format!("{}/small", server.url()))
732            .send()
733            .await
734            .unwrap();
735        assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
736    }
737
738    #[tokio::test]
739    async fn chunked_body_without_content_length_still_hits_the_cap() {
740        let mut server = mockito::Server::new_async().await;
741        server
742            .mock("GET", "/chunked")
743            .with_status(200)
744            .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
745            .create_async()
746            .await;
747
748        let response = reqwest::Client::new()
749            .get(format!("{}/chunked", server.url()))
750            .send()
751            .await
752            .unwrap();
753        assert!(response.content_length().is_none());
754        let error = read_body_capped(response, 1024).await.unwrap_err();
755        assert!(error.to_string().contains("exceeds"), "{error}");
756    }
757
758    #[tokio::test]
759    async fn same_origin_redirects_still_work_with_vendor_headers() {
760        let mut server = mockito::Server::new_async().await;
761        let redirect = server
762            .mock("GET", "/start")
763            .match_header("x-api-key", "secret")
764            .with_status(302)
765            .with_header("location", "/finish")
766            .create_async()
767            .await;
768        let finish = server
769            .mock("GET", "/finish")
770            .match_header("x-api-key", "secret")
771            .with_status(200)
772            .create_async()
773            .await;
774        let client = reqwest::Client::builder()
775            .redirect(same_origin_redirect_policy())
776            .build()
777            .unwrap();
778
779        let response = client
780            .get(format!("{}/start", server.url()))
781            .header("x-api-key", "secret")
782            .send()
783            .await
784            .unwrap();
785
786        assert_eq!(response.status(), reqwest::StatusCode::OK);
787        redirect.assert_async().await;
788        finish.assert_async().await;
789    }
790
791    #[tokio::test]
792    async fn cross_origin_redirects_are_not_followed_with_vendor_headers() {
793        let mut origin = mockito::Server::new_async().await;
794        let mut target = mockito::Server::new_async().await;
795        let target_url = format!("{}/capture", target.url());
796        let redirect = origin
797            .mock("GET", "/start")
798            .match_header("x-api-key", "secret")
799            .with_status(302)
800            .with_header("location", &target_url)
801            .create_async()
802            .await;
803        let capture = target
804            .mock("GET", "/capture")
805            .expect(0)
806            .create_async()
807            .await;
808        let client = reqwest::Client::builder()
809            .redirect(same_origin_redirect_policy())
810            .build()
811            .unwrap();
812
813        let response = client
814            .get(format!("{}/start", origin.url()))
815            .header("x-api-key", "secret")
816            .send()
817            .await
818            .unwrap();
819
820        assert_eq!(response.status(), reqwest::StatusCode::FOUND);
821        redirect.assert_async().await;
822        capture.assert_async().await;
823    }
824
825    #[test]
826    fn vendor_id_slug_round_trip() {
827        for id in VendorId::all() {
828            assert_eq!(
829                id.slug(),
830                serde_json::to_value(id).unwrap().as_str().unwrap()
831            );
832        }
833    }
834}