1use 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
15pub const HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
18
19pub const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
24
25pub(crate) const VENDOR_SECRET_ENV_VARS: &[&str] = &[
28 "ZAI_API_KEY",
29 "OPENROUTER_API_KEY",
30 "OPENROUTER_MANAGEMENT_API_KEY",
31 "DEEPSEEK_API_KEY",
32 "DEEPINFRA_API_KEY",
33 "KIMI_API_KEY",
34 "KILO_API_KEY",
35 "NOVITA_API_KEY",
36 "SYNTHETIC_API_KEY",
37 "MINIMAX_API_KEY",
38 "MOONSHOT_API_KEY",
39 "XAI_MANAGEMENT_KEY",
40 "ANTHROPIC_ADMIN_KEY",
41 "XAI_API_KEY",
42 "GROK_API_KEY",
43 "OPENCODE_GO_API_KEY",
44 "COMMANDCODE_API_KEY",
45 "ORCAROUTER_API_KEY",
46 "LYCEUM_API_KEY",
47 "GITHUB_COPILOT_TOKEN",
48 "GH_TOKEN",
49 "GITHUB_TOKEN",
50 "OLLAMA_API_KEY",
51 "CLINE_API_KEY",
52];
53
54fn registered_secret_env_vars() -> &'static Mutex<BTreeSet<&'static str>> {
59 static REGISTERED: OnceLock<Mutex<BTreeSet<&'static str>>> = OnceLock::new();
60 REGISTERED.get_or_init(|| Mutex::new(BTreeSet::new()))
61}
62
63pub fn register_secret_env_vars(names: &[String]) {
74 let mut registered = registered_secret_env_vars()
75 .lock()
76 .unwrap_or_else(|poisoned| poisoned.into_inner());
77 for name in names {
78 if !crate::config::is_valid_env_var_name(name)
79 || VENDOR_SECRET_ENV_VARS.contains(&name.as_str())
80 || registered.contains(name.as_str())
81 {
82 continue;
83 }
84 registered.insert(Box::leak(name.clone().into_boxed_str()));
85 }
86}
87
88pub(crate) fn vendor_secret_env_vars_to_remove(keep: &[&str]) -> Vec<&'static str> {
89 let registered = registered_secret_env_vars()
90 .lock()
91 .unwrap_or_else(|poisoned| poisoned.into_inner());
92 VENDOR_SECRET_ENV_VARS
93 .iter()
94 .copied()
95 .chain(registered.iter().copied())
96 .filter(|var| !keep.contains(var))
97 .collect()
98}
99
100pub fn same_origin_redirect_policy() -> reqwest::redirect::Policy {
105 reqwest::redirect::Policy::custom(|attempt| {
106 if attempt.previous().len() >= 10 {
107 return attempt.error("too many redirects");
108 }
109 let Some(origin) = attempt.previous().first() else {
110 return attempt.stop();
111 };
112 let target = attempt.url();
113 if target.scheme() == origin.scheme()
114 && target.host_str() == origin.host_str()
115 && target.port_or_known_default() == origin.port_or_known_default()
116 {
117 attempt.follow()
118 } else {
119 attempt.stop()
120 }
121 })
122}
123
124pub async fn read_body_capped(
132 mut resp: reqwest::Response,
133 max: usize,
134) -> crate::error::Result<Vec<u8>> {
135 let too_big = |n: u64| {
136 crate::error::AppError::Schema(format!(
137 "response body exceeds the {max}-byte limit ({n} bytes); refusing to buffer it"
138 ))
139 };
140 if let Some(len) = resp.content_length()
141 && len > max as u64
142 {
143 return Err(too_big(len));
144 }
145 let mut buf: Vec<u8> = Vec::new();
146 while let Some(chunk) = resp.chunk().await? {
147 if chunk.len() > max.saturating_sub(buf.len()) {
148 return Err(too_big(buf.len().saturating_add(chunk.len()) as u64));
149 }
150 buf.extend_from_slice(&chunk);
151 }
152 Ok(buf)
153}
154
155#[derive(
157 Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize,
158)]
159#[serde(rename_all = "lowercase")]
160pub enum VendorId {
161 Anthropic,
162 #[serde(rename = "anthropic_api")]
163 AnthropicApi,
164 Openai,
165 Copilot,
166 Zai,
167 Openrouter,
168 Deepseek,
169 Deepinfra,
170 Kimi,
171 Kilo,
172 Novita,
173 Synthetic,
174 Moonshot,
175 Grok,
176 Supergrok,
177 Grokbot,
178 Antigravity,
179 Cursor,
180 Minimax,
181 Kiro,
182 #[serde(rename = "nous")]
183 NousResearch,
184 #[serde(rename = "opencode-go")]
185 OpenCodeGo,
186 #[serde(rename = "commandcode")]
187 CommandCode,
188 Ollama,
189 OrcaRouter,
190 ModelStudio,
191 Lyceum,
192 Devin,
193 Cline,
194 #[serde(rename = "muse-code")]
195 MuseCode,
196 Droid,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
203#[serde(rename_all = "lowercase")]
204pub enum AuthKind {
205 Oauth,
207 ApiKey,
209 Local,
212}
213
214impl AuthKind {
215 pub const fn as_str(self) -> &'static str {
216 match self {
217 AuthKind::Oauth => "oauth",
218 AuthKind::ApiKey => "apikey",
219 AuthKind::Local => "local",
220 }
221 }
222}
223
224impl VendorId {
225 pub fn slug(self) -> &'static str {
226 match self {
227 VendorId::Anthropic => "anthropic",
228 VendorId::AnthropicApi => "anthropic_api",
229 VendorId::Openai => "openai",
230 VendorId::Copilot => "copilot",
231 VendorId::Zai => "zai",
232 VendorId::Openrouter => "openrouter",
233 VendorId::Deepseek => "deepseek",
234 VendorId::Deepinfra => "deepinfra",
235 VendorId::Kimi => "kimi",
236 VendorId::Kilo => "kilo",
237 VendorId::Novita => "novita",
238 VendorId::Synthetic => "synthetic",
239 VendorId::Moonshot => "moonshot",
240 VendorId::Grok => "grok",
241 VendorId::Supergrok => "supergrok",
242 VendorId::Grokbot => "grokbot",
243 VendorId::Antigravity => "antigravity",
244 VendorId::Cursor => "cursor",
245 VendorId::Minimax => "minimax",
246 VendorId::Kiro => "kiro",
247 VendorId::NousResearch => "nous",
248 VendorId::OpenCodeGo => "opencode-go",
249 VendorId::CommandCode => "commandcode",
250 VendorId::Ollama => "ollama",
251 VendorId::OrcaRouter => "orcarouter",
252 VendorId::ModelStudio => "modelstudio",
253 VendorId::Lyceum => "lyceum",
254 VendorId::Devin => "devin",
255 VendorId::MuseCode => "muse-code",
256 VendorId::Cline => "cline",
257 VendorId::Droid => "droid",
258 }
259 }
260
261 pub fn display_name(self) -> &'static str {
265 match self {
266 VendorId::Anthropic => "Claude",
267 VendorId::AnthropicApi => "Anthropic API",
268 VendorId::Openai => "Codex",
269 VendorId::Copilot => "GitHub Copilot",
270 VendorId::Zai => "Z.AI",
271 VendorId::Openrouter => "OpenRouter",
272 VendorId::Cline => "Cline",
273 VendorId::Deepseek => "DeepSeek",
274 VendorId::Deepinfra => "DeepInfra",
275 VendorId::Kimi => "Kimi",
276 VendorId::Kilo => "Kilo",
277 VendorId::Novita => "Novita",
278 VendorId::Synthetic => "Synthetic",
279 VendorId::Moonshot => "Moonshot",
280 VendorId::Grok => "Grok",
281 VendorId::Supergrok => "SuperGrok",
282 VendorId::Grokbot => "Grok Bot",
283 VendorId::Antigravity => "Antigravity",
284 VendorId::Cursor => "Cursor",
285 VendorId::Minimax => "MiniMax",
286 VendorId::Kiro => "Kiro",
287 VendorId::NousResearch => "Nous Research",
288 VendorId::OpenCodeGo => "OpenCode Go",
289 VendorId::CommandCode => "Command Code",
290 VendorId::Ollama => "Ollama Cloud",
291 VendorId::OrcaRouter => "OrcaRouter",
292 VendorId::ModelStudio => "Model Studio",
293 VendorId::Lyceum => "Lyceum",
294 VendorId::Devin => "Devin",
295 VendorId::MuseCode => "Muse Code",
296 VendorId::Droid => "Droid",
297 }
298 }
299
300 pub const fn bar_icon(self) -> &'static str {
304 match self {
305 VendorId::Anthropic => "",
306 VendorId::AnthropicApi => "",
307 VendorId::Openai => "",
308 VendorId::Copilot => "",
309 VendorId::Zai => VendorId::Zai.short_name(),
310 VendorId::Openrouter => "",
311 VendorId::Deepseek => "",
312 VendorId::Deepinfra => VendorId::Deepinfra.short_name(),
313 VendorId::Kimi => VendorId::Kimi.short_name(),
314 VendorId::Kilo => "",
315 VendorId::Novita => "",
316 VendorId::Synthetic => VendorId::Synthetic.short_name(),
317 VendorId::Moonshot => VendorId::Moonshot.short_name(),
318 VendorId::Grok | VendorId::Supergrok => "",
319 VendorId::Grokbot => VendorId::Grokbot.short_name(),
320 VendorId::Antigravity => VendorId::Antigravity.short_name(),
321 VendorId::Cursor => "❯",
322 VendorId::Minimax => VendorId::Minimax.short_name(),
323 VendorId::Kiro => "◆",
324 VendorId::NousResearch => VendorId::NousResearch.short_name(),
325 VendorId::OpenCodeGo => VendorId::OpenCodeGo.short_name(),
326 VendorId::CommandCode => VendorId::CommandCode.short_name(),
327 VendorId::Ollama => VendorId::Ollama.short_name(),
330 VendorId::OrcaRouter => VendorId::OrcaRouter.short_name(),
332 VendorId::ModelStudio => VendorId::ModelStudio.short_name(),
334 VendorId::Lyceum => VendorId::Lyceum.short_name(),
335 VendorId::Devin => VendorId::Devin.short_name(),
336 VendorId::MuseCode => VendorId::MuseCode.short_name(),
337 VendorId::Cline => VendorId::Cline.short_name(),
338 VendorId::Droid => VendorId::Droid.short_name(),
339 }
340 }
341
342 pub const fn short_name(self) -> &'static str {
347 match self {
348 VendorId::Anthropic => "cld",
349 VendorId::AnthropicApi => "aac",
350 VendorId::Openai => "gpt",
351 VendorId::Copilot => "ghc",
352 VendorId::Zai => "zai",
353 VendorId::Openrouter => "opr",
354 VendorId::Deepseek => "dsk",
355 VendorId::Deepinfra => "dif",
356 VendorId::Kimi => "kmi",
357 VendorId::Kilo => "klo",
358 VendorId::Novita => "nvt",
359 VendorId::Synthetic => "syn",
360 VendorId::Moonshot => "msh",
361 VendorId::Grok => "grk",
362 VendorId::Supergrok => "sgk",
363 VendorId::Grokbot => "gbt",
364 VendorId::Antigravity => "agy",
365 VendorId::Cursor => "cur",
366 VendorId::Minimax => "mmx",
367 VendorId::Kiro => "kir",
368 VendorId::NousResearch => "nrs",
369 VendorId::OpenCodeGo => "ocg",
370 VendorId::CommandCode => "cmc",
371 VendorId::Ollama => "oll",
372 VendorId::OrcaRouter => "orc",
373 VendorId::ModelStudio => "mst",
374 VendorId::Lyceum => "lyc",
375 VendorId::Devin => "dvn",
376 VendorId::MuseCode => "msc",
377 VendorId::Cline => "cln",
378 VendorId::Droid => "drd",
379 }
380 }
381
382 pub const fn config_section(self) -> &'static str {
390 match self {
391 VendorId::Anthropic => "anthropic",
392 VendorId::AnthropicApi => "anthropic_api",
393 VendorId::Openai => "openai",
394 VendorId::Copilot => "copilot",
395 VendorId::Zai => "zai",
396 VendorId::Openrouter => "openrouter",
397 VendorId::Deepseek => "deepseek",
398 VendorId::Deepinfra => "deepinfra",
399 VendorId::Kimi => "kimi",
400 VendorId::Kilo => "kilo",
401 VendorId::Novita => "novita",
402 VendorId::Synthetic => "synthetic",
403 VendorId::Moonshot => "moonshot",
404 VendorId::Grok => "grok",
405 VendorId::Supergrok => "supergrok",
406 VendorId::Grokbot => "grokbot",
407 VendorId::Antigravity => "antigravity",
408 VendorId::Cursor => "cursor",
409 VendorId::Minimax => "minimax",
410 VendorId::Kiro => "kiro",
411 VendorId::NousResearch => "nous",
412 VendorId::OpenCodeGo => "opencode-go",
413 VendorId::CommandCode => "commandcode",
414 VendorId::Cline => "cline",
415 VendorId::Ollama => "ollama",
416 VendorId::OrcaRouter => "orcarouter",
417 VendorId::ModelStudio => "modelstudio",
418 VendorId::Lyceum => "lyceum",
419 VendorId::Devin => "devin",
420 VendorId::MuseCode => "muse-code",
421 VendorId::Droid => "droid",
422 }
423 }
424
425 pub const fn auth_kind(self) -> AuthKind {
431 match self {
432 VendorId::Anthropic
433 | VendorId::Openai
434 | VendorId::Copilot
435 | VendorId::NousResearch
436 | VendorId::Cline
437 | VendorId::CommandCode => AuthKind::Oauth,
438 VendorId::AnthropicApi
439 | VendorId::Zai
440 | VendorId::Openrouter
441 | VendorId::Deepseek
442 | VendorId::Deepinfra
443 | VendorId::Kimi
444 | VendorId::Kilo
445 | VendorId::Novita
446 | VendorId::Synthetic
447 | VendorId::Moonshot
448 | VendorId::Grok
449 | VendorId::Minimax
450 | VendorId::OpenCodeGo
451 | VendorId::Ollama
452 | VendorId::OrcaRouter
453 | VendorId::Lyceum => AuthKind::ApiKey,
454 VendorId::Supergrok
464 | VendorId::Antigravity
465 | VendorId::Cursor
466 | VendorId::Kiro
467 | VendorId::Grokbot
468 | VendorId::ModelStudio
469 | VendorId::Devin
470 | VendorId::MuseCode
471 | VendorId::Droid => AuthKind::Local,
472 }
473 }
474
475 pub const fn api_key_env(self) -> &'static str {
480 match self {
481 VendorId::AnthropicApi => "ANTHROPIC_ADMIN_KEY",
482 VendorId::Zai => "ZAI_API_KEY",
483 VendorId::Openrouter => "OPENROUTER_API_KEY",
484 VendorId::Deepseek => "DEEPSEEK_API_KEY",
485 VendorId::Deepinfra => "DEEPINFRA_API_KEY",
486 VendorId::Kimi => "KIMI_API_KEY",
487 VendorId::Kilo => "KILO_API_KEY",
488 VendorId::Novita => "NOVITA_API_KEY",
489 VendorId::Synthetic => "SYNTHETIC_API_KEY",
490 VendorId::Moonshot => "MOONSHOT_API_KEY",
491 VendorId::Grok => "XAI_MANAGEMENT_KEY",
492 VendorId::Minimax => "MINIMAX_API_KEY",
493 VendorId::OpenCodeGo => "OPENCODE_GO_API_KEY",
494 VendorId::Ollama => "OLLAMA_API_KEY",
495 VendorId::OrcaRouter => "ORCAROUTER_API_KEY",
496 VendorId::Lyceum => "LYCEUM_API_KEY",
497 VendorId::Copilot => "GITHUB_COPILOT_TOKEN",
501 VendorId::CommandCode => "COMMANDCODE_API_KEY",
502 VendorId::Cline => "CLINE_API_KEY",
503 VendorId::Anthropic
504 | VendorId::Openai
505 | VendorId::Supergrok
506 | VendorId::Grokbot
507 | VendorId::Antigravity
508 | VendorId::Cursor
509 | VendorId::Kiro
510 | VendorId::NousResearch
511 | VendorId::ModelStudio
512 | VendorId::Devin
513 | VendorId::MuseCode
514 | VendorId::Droid => "",
515 }
516 }
517
518 pub const fn sign_in_hint(self) -> &'static str {
531 match self {
532 VendorId::Anthropic => "Run `claude` in a terminal, then Refresh.",
533 VendorId::Openai => "Run `codex login` in a terminal, then Refresh.",
534 VendorId::Copilot => "Run `gh auth login` in a terminal, then Refresh.",
535 VendorId::Kiro => "Run `kiro-cli login` in a terminal, then Refresh.",
536 VendorId::Kimi => "Run `kimi` in a terminal, or set an API key.",
537 VendorId::CommandCode => "Run `commandcode` in a terminal, then Refresh.",
538 VendorId::Cline => "Run `cline` in a terminal, then Refresh.",
539 VendorId::NousResearch => {
540 "Run `ai-usagebar auth nous login` in a terminal, then Refresh."
541 }
542 VendorId::Cursor => "Sign in to the Cursor app, then Refresh.",
543 VendorId::Antigravity => "Open Antigravity or run `agy`, then Refresh.",
544 VendorId::Supergrok => "Sign in with `grok`, then Refresh.",
545 VendorId::Grokbot => "Install and sign in to the Grok Bot desktop app, then Refresh.",
546 VendorId::ModelStudio => {
548 "Install the official `bl` CLI and run `bl auth login --console`, then Refresh."
549 }
550 VendorId::Devin => "Sign in with the official Devin CLI, then Refresh.",
551 VendorId::Droid => "Sign in with `droid` on Linux, then Refresh.",
552 VendorId::MuseCode => "Run `muse login` in a terminal, then Refresh.",
553 VendorId::AnthropicApi
558 | VendorId::Zai
559 | VendorId::Openrouter
560 | VendorId::Deepseek
561 | VendorId::Deepinfra
562 | VendorId::Kilo
563 | VendorId::Novita
564 | VendorId::Synthetic
565 | VendorId::Moonshot
566 | VendorId::Grok
567 | VendorId::Minimax
568 | VendorId::OpenCodeGo
569 | VendorId::Ollama
570 | VendorId::OrcaRouter
571 | VendorId::Lyceum => "Add an API key in Settings, then Refresh.",
572 }
573 }
574
575 pub const fn login_command(self) -> &'static str {
576 match self {
577 VendorId::Anthropic => "claude",
578 VendorId::Openai => "codex login",
579 VendorId::Copilot => "gh auth login",
580 VendorId::CommandCode => "commandcode",
581 VendorId::Cline => "cline",
582 VendorId::NousResearch => "ai-usagebar auth nous login",
583 VendorId::Kiro => "kiro-cli login",
584 VendorId::ModelStudio => "bl auth login --console",
586 VendorId::Devin => "",
587 VendorId::Droid => "droid",
588 VendorId::MuseCode => "muse login",
589 VendorId::Kimi => "kimi",
592 VendorId::AnthropicApi
593 | VendorId::Zai
594 | VendorId::Openrouter
595 | VendorId::Deepseek
596 | VendorId::Deepinfra
597 | VendorId::Kilo
598 | VendorId::Novita
599 | VendorId::Synthetic
600 | VendorId::Moonshot
601 | VendorId::Grok
602 | VendorId::Supergrok
603 | VendorId::Grokbot
604 | VendorId::Antigravity
605 | VendorId::Cursor
606 | VendorId::Minimax
607 | VendorId::OpenCodeGo
608 | VendorId::Ollama
609 | VendorId::OrcaRouter
610 | VendorId::Lyceum => "",
611 }
612 }
613
614 pub const fn auto_detectable(self) -> bool {
618 !matches!(self, VendorId::Devin | VendorId::MuseCode | VendorId::Droid)
619 }
620
621 pub const fn all() -> &'static [VendorId] {
622 &[
623 VendorId::Anthropic,
624 VendorId::AnthropicApi,
625 VendorId::Openai,
626 VendorId::Copilot,
627 VendorId::Zai,
628 VendorId::Openrouter,
629 VendorId::Deepseek,
630 VendorId::Deepinfra,
631 VendorId::Kimi,
632 VendorId::Kilo,
633 VendorId::Novita,
634 VendorId::Synthetic,
635 VendorId::Moonshot,
636 VendorId::Grok,
637 VendorId::Supergrok,
638 VendorId::Grokbot,
639 VendorId::Antigravity,
640 VendorId::Cursor,
641 VendorId::Minimax,
642 VendorId::Kiro,
643 VendorId::NousResearch,
644 VendorId::OpenCodeGo,
645 VendorId::CommandCode,
646 VendorId::Ollama,
647 VendorId::OrcaRouter,
648 VendorId::ModelStudio,
649 VendorId::Lyceum,
650 VendorId::Devin,
651 VendorId::Cline,
652 VendorId::MuseCode,
653 VendorId::Droid,
654 ]
655 }
656
657 pub fn from_slug(slug: &str) -> Option<Self> {
662 Self::all().iter().copied().find(|id| id.slug() == slug)
663 }
664}
665
666pub type VendorOutcome = crate::outcome::Outcome<VendorSnapshot>;
671
672#[derive(Debug, Clone)]
674pub struct RenderOpts {
675 pub format: Option<String>,
676 pub tooltip_format: Option<String>,
677 pub icon: Option<String>,
678 pub pace_tolerance: u32,
679 pub format_pace_color: bool,
680 pub tooltip_pace_pts: bool,
681}
682
683impl RenderOpts {
684 pub fn from_cli(cli: &Cli) -> Self {
685 Self {
686 format: cli.format.clone(),
687 tooltip_format: cli.tooltip_format.clone(),
688 icon: cli.icon.clone(),
689 pace_tolerance: cli.pace_tolerance,
690 format_pace_color: cli.format_pace_color,
691 tooltip_pace_pts: cli.tooltip_pace_pts,
692 }
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn every_vendor_has_stable_machine_and_display_names() {
702 for vendor in VendorId::all() {
703 assert!(!vendor.slug().is_empty());
704 assert!(!vendor.display_name().is_empty());
705 }
706 assert_eq!(VendorId::Anthropic.slug(), "anthropic");
707 assert_eq!(VendorId::Anthropic.display_name(), "Claude");
708 assert_eq!(VendorId::Openai.display_name(), "Codex");
709 assert_eq!(VendorId::Zai.display_name(), "Z.AI");
710 assert!(VendorId::all().contains(&VendorId::Lyceum));
711 assert_eq!(VendorId::Lyceum.slug(), "lyceum");
712 assert_eq!(VendorId::Lyceum.display_name(), "Lyceum");
713 assert_eq!(VendorId::Lyceum.short_name(), "lyc");
714 assert_eq!(VendorId::Lyceum.api_key_env(), "LYCEUM_API_KEY");
715 assert_eq!(VendorId::Lyceum.config_section(), "lyceum");
716 assert_eq!(VendorId::Lyceum.auth_kind(), AuthKind::ApiKey);
717 }
718
719 #[test]
723 fn every_vendor_short_name_is_a_unique_three_letter_code() {
724 let mut seen = std::collections::BTreeSet::new();
725 for vendor in VendorId::all() {
726 let short = vendor.short_name();
727 assert_eq!(short.len(), 3, "{} is not three letters", vendor.slug());
728 assert!(
729 short.chars().all(|c| c.is_ascii_lowercase()),
730 "{} is not lowercase ascii",
731 vendor.slug()
732 );
733 assert!(seen.insert(short), "{short} is used by two vendors");
734 }
735 assert_eq!(VendorId::Anthropic.short_name(), "cld");
736 assert_eq!(VendorId::Openai.short_name(), "gpt");
737 assert_eq!(VendorId::Zai.short_name(), "zai");
738 assert_eq!(VendorId::Antigravity.short_name(), "agy");
739 }
740
741 #[test]
747 fn every_vendor_has_a_bar_icon_and_no_two_share_one() {
748 use std::collections::BTreeMap;
749
750 let mut by_icon: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
751 for vendor in VendorId::all() {
752 assert!(!vendor.bar_icon().is_empty(), "{}", vendor.slug());
753 by_icon
754 .entry(vendor.bar_icon())
755 .or_default()
756 .push(vendor.slug());
757 }
758
759 let shared: Vec<_> = by_icon
760 .iter()
761 .filter(|(_, vendors)| vendors.len() > 1)
762 .filter(|(_, vendors)| vendors.as_slice() != ["grok", "supergrok"])
763 .collect();
764 assert!(
765 shared.is_empty(),
766 "these providers are indistinguishable in a bar that shows them \
767 side by side: {shared:#?}"
768 );
769 assert_eq!(VendorId::Anthropic.bar_icon(), "");
770 assert_eq!(VendorId::Openai.bar_icon(), "");
771 assert_eq!(VendorId::Supergrok.bar_icon(), VendorId::Grok.bar_icon());
772 assert_eq!(VendorId::CommandCode.bar_icon(), "cmc");
773 assert_eq!(VendorId::Cline.bar_icon(), "cln");
774 }
775
776 #[test]
777 fn new_vendor_contracts_keep_public_names_and_slugs() {
778 assert_eq!(VendorId::NousResearch.slug(), "nous");
779 assert_eq!(VendorId::NousResearch.display_name(), "Nous Research");
780 assert_eq!(VendorId::OpenCodeGo.slug(), "opencode-go");
781 assert_eq!(VendorId::OpenCodeGo.display_name(), "OpenCode Go");
782 assert_eq!(
783 serde_json::to_value(VendorId::OpenCodeGo).unwrap(),
784 serde_json::json!("opencode-go")
785 );
786 assert_eq!(VendorId::Devin.slug(), "devin");
787 assert_eq!(VendorId::Devin.display_name(), "Devin");
788 assert_eq!(VendorId::Devin.short_name(), "dvn");
789 assert_eq!(VendorId::Devin.auth_kind(), AuthKind::Local);
790 assert_eq!(VendorId::Droid.slug(), "droid");
791 assert_eq!(VendorId::Droid.display_name(), "Droid");
792 assert_eq!(VendorId::Droid.short_name(), "drd");
793 assert_eq!(VendorId::Droid.auth_kind(), AuthKind::Local);
794 assert_eq!(VendorId::Cline.slug(), "cline");
795 assert_eq!(VendorId::Cline.display_name(), "Cline");
796 assert_eq!(VendorId::Cline.short_name(), "cln");
797 assert_eq!(VendorId::Cline.auth_kind(), AuthKind::Oauth);
798 }
799
800 #[test]
801 fn key_only_vendors_prompt_for_settings_api_key() {
802 for vendor in VendorId::all() {
803 if vendor.auth_kind() == AuthKind::ApiKey && vendor.login_command().is_empty() {
804 assert_eq!(
805 vendor.sign_in_hint(),
806 "Add an API key in Settings, then Refresh.",
807 "{}",
808 vendor.slug()
809 );
810 }
811 }
812 assert_eq!(
813 VendorId::Supergrok.sign_in_hint(),
814 "Sign in with `grok`, then Refresh."
815 );
816 assert_eq!(
817 VendorId::Grok.sign_in_hint(),
818 "Add an API key in Settings, then Refresh."
819 );
820 }
821
822 #[test]
823 fn vendor_secret_env_vars_cover_config_defaults() {
824 let configured_defaults = [
825 "ZAI_API_KEY",
826 "OPENROUTER_API_KEY",
827 "OPENROUTER_MANAGEMENT_API_KEY",
828 "DEEPSEEK_API_KEY",
829 "DEEPINFRA_API_KEY",
830 "KIMI_API_KEY",
831 "KILO_API_KEY",
832 "NOVITA_API_KEY",
833 "SYNTHETIC_API_KEY",
834 "MINIMAX_API_KEY",
835 "MOONSHOT_API_KEY",
836 "XAI_MANAGEMENT_KEY",
837 "ANTHROPIC_ADMIN_KEY",
838 "GITHUB_COPILOT_TOKEN",
839 "ORCAROUTER_API_KEY",
840 "LYCEUM_API_KEY",
841 ];
842 for name in configured_defaults {
843 assert!(VENDOR_SECRET_ENV_VARS.contains(&name), "missing {name}");
844 }
845 }
846
847 #[test]
848 fn vars_to_remove_preserves_only_requested_grok_credentials() {
849 let removed = vendor_secret_env_vars_to_remove(&["XAI_API_KEY", "GROK_API_KEY"]);
850 assert!(!removed.contains(&"XAI_API_KEY"));
851 assert!(!removed.contains(&"GROK_API_KEY"));
852 assert!(removed.contains(&"ANTHROPIC_ADMIN_KEY"));
853 assert!(removed.contains(&"OPENROUTER_API_KEY"));
854 let builtins = removed
857 .iter()
858 .filter(|var| VENDOR_SECRET_ENV_VARS.contains(var))
859 .count();
860 assert_eq!(builtins, VENDOR_SECRET_ENV_VARS.len() - 2);
861 }
862
863 #[test]
864 fn a_registered_custom_env_var_is_scrubbed_like_a_builtin_one() {
865 let name = "AI_USAGEBAR_TEST_CUSTOM_TOKEN_7F3A";
866 assert!(!vendor_secret_env_vars_to_remove(&[]).contains(&name));
867
868 register_secret_env_vars(&[name.to_string(), "not a name!".to_string()]);
869 register_secret_env_vars(&[name.to_string()]);
870
871 let removed = vendor_secret_env_vars_to_remove(&[]);
872 assert_eq!(
873 removed.iter().filter(|var| **var == name).count(),
874 1,
875 "registering twice must not list it twice: {removed:?}"
876 );
877 assert!(!removed.contains(&"not a name!"), "{removed:?}");
878 assert!(
879 !vendor_secret_env_vars_to_remove(&[name]).contains(&name),
880 "`keep` applies to registered names too"
881 );
882 }
883
884 #[test]
885 fn copilot_token_is_removed_before_unrelated_subprocesses_launch() {
886 let removed = vendor_secret_env_vars_to_remove(&[]);
887 assert!(removed.contains(&"GITHUB_COPILOT_TOKEN"));
888 }
889
890 #[tokio::test]
891 async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
892 let mut server = mockito::Server::new_async().await;
893 server
894 .mock("GET", "/big")
895 .with_status(200)
896 .with_body("x".repeat(4096))
897 .create_async()
898 .await;
899 server
900 .mock("GET", "/small")
901 .with_status(200)
902 .with_body("hello")
903 .create_async()
904 .await;
905
906 let client = reqwest::Client::new();
907
908 let resp = client
910 .get(format!("{}/big", server.url()))
911 .send()
912 .await
913 .unwrap();
914 let err = read_body_capped(resp, 1024).await.unwrap_err();
915 assert!(
916 err.to_string().contains("exceeds"),
917 "unexpected error: {err}"
918 );
919
920 let resp = client
922 .get(format!("{}/small", server.url()))
923 .send()
924 .await
925 .unwrap();
926 assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
927 }
928
929 #[tokio::test]
930 async fn chunked_body_without_content_length_still_hits_the_cap() {
931 let mut server = mockito::Server::new_async().await;
932 server
933 .mock("GET", "/chunked")
934 .with_status(200)
935 .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
936 .create_async()
937 .await;
938
939 let response = reqwest::Client::new()
940 .get(format!("{}/chunked", server.url()))
941 .send()
942 .await
943 .unwrap();
944 assert!(response.content_length().is_none());
945 let error = read_body_capped(response, 1024).await.unwrap_err();
946 assert!(error.to_string().contains("exceeds"), "{error}");
947 }
948
949 #[tokio::test]
950 async fn same_origin_redirects_still_work_with_vendor_headers() {
951 let mut server = mockito::Server::new_async().await;
952 let redirect = server
953 .mock("GET", "/start")
954 .match_header("x-api-key", "secret")
955 .with_status(302)
956 .with_header("location", "/finish")
957 .create_async()
958 .await;
959 let finish = server
960 .mock("GET", "/finish")
961 .match_header("x-api-key", "secret")
962 .with_status(200)
963 .create_async()
964 .await;
965 let client = reqwest::Client::builder()
966 .redirect(same_origin_redirect_policy())
967 .build()
968 .unwrap();
969
970 let response = client
971 .get(format!("{}/start", server.url()))
972 .header("x-api-key", "secret")
973 .send()
974 .await
975 .unwrap();
976
977 assert_eq!(response.status(), reqwest::StatusCode::OK);
978 redirect.assert_async().await;
979 finish.assert_async().await;
980 }
981
982 #[tokio::test]
983 async fn cross_origin_redirects_are_not_followed_with_vendor_headers() {
984 let mut origin = mockito::Server::new_async().await;
985 let mut target = mockito::Server::new_async().await;
986 let target_url = format!("{}/capture", target.url());
987 let redirect = origin
988 .mock("GET", "/start")
989 .match_header("x-api-key", "secret")
990 .with_status(302)
991 .with_header("location", &target_url)
992 .create_async()
993 .await;
994 let capture = target
995 .mock("GET", "/capture")
996 .expect(0)
997 .create_async()
998 .await;
999 let client = reqwest::Client::builder()
1000 .redirect(same_origin_redirect_policy())
1001 .build()
1002 .unwrap();
1003
1004 let response = client
1005 .get(format!("{}/start", origin.url()))
1006 .header("x-api-key", "secret")
1007 .send()
1008 .await
1009 .unwrap();
1010
1011 assert_eq!(response.status(), reqwest::StatusCode::FOUND);
1012 redirect.assert_async().await;
1013 capture.assert_async().await;
1014 }
1015
1016 #[test]
1017 fn vendor_id_slug_round_trip() {
1018 for id in VendorId::all() {
1019 assert_eq!(
1020 id.slug(),
1021 serde_json::to_value(id).unwrap().as_str().unwrap()
1022 );
1023 }
1024 }
1025
1026 #[test]
1030 fn from_slug_resolves_every_slug_and_rejects_everything_else() {
1031 for id in VendorId::all() {
1032 assert_eq!(VendorId::from_slug(id.slug()), Some(*id));
1033 }
1034 assert_eq!(
1035 VendorId::from_slug("anthropic_api"),
1036 Some(VendorId::AnthropicApi)
1037 );
1038 assert_eq!(
1039 VendorId::from_slug("opencode-go"),
1040 Some(VendorId::OpenCodeGo)
1041 );
1042 assert_eq!(VendorId::from_slug(""), None);
1043 assert_eq!(VendorId::from_slug("custom"), None);
1044 assert_eq!(VendorId::from_slug("Anthropic"), None);
1045 }
1046}