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