1use std::time::Duration;
7
8use clap::ValueEnum;
9
10use crate::usage::VendorSnapshot;
11use crate::widget::cli::Cli;
12
13pub const HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
16
17pub const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
22
23pub(crate) const VENDOR_SECRET_ENV_VARS: &[&str] = &[
26 "ZAI_API_KEY",
27 "OPENROUTER_API_KEY",
28 "DEEPSEEK_API_KEY",
29 "KIMI_API_KEY",
30 "KILO_API_KEY",
31 "NOVITA_API_KEY",
32 "MINIMAX_API_KEY",
33 "MOONSHOT_API_KEY",
34 "XAI_MANAGEMENT_KEY",
35 "ANTHROPIC_ADMIN_KEY",
36 "XAI_API_KEY",
37 "GROK_API_KEY",
38 "OPENCODE_GO_API_KEY",
39 "COMMANDCODE_API_KEY",
40 "GITHUB_COPILOT_TOKEN",
41 "GH_TOKEN",
42 "GITHUB_TOKEN",
43];
44
45pub(crate) fn vendor_secret_env_vars_to_remove(keep: &[&str]) -> Vec<&'static str> {
46 VENDOR_SECRET_ENV_VARS
47 .iter()
48 .copied()
49 .filter(|var| !keep.contains(var))
50 .collect()
51}
52
53pub fn same_origin_redirect_policy() -> reqwest::redirect::Policy {
58 reqwest::redirect::Policy::custom(|attempt| {
59 if attempt.previous().len() >= 10 {
60 return attempt.error("too many redirects");
61 }
62 let Some(origin) = attempt.previous().first() else {
63 return attempt.stop();
64 };
65 let target = attempt.url();
66 if target.scheme() == origin.scheme()
67 && target.host_str() == origin.host_str()
68 && target.port_or_known_default() == origin.port_or_known_default()
69 {
70 attempt.follow()
71 } else {
72 attempt.stop()
73 }
74 })
75}
76
77pub async fn read_body_capped(
85 mut resp: reqwest::Response,
86 max: usize,
87) -> crate::error::Result<Vec<u8>> {
88 let too_big = |n: u64| {
89 crate::error::AppError::Schema(format!(
90 "response body exceeds the {max}-byte limit ({n} bytes); refusing to buffer it"
91 ))
92 };
93 if let Some(len) = resp.content_length()
94 && len > max as u64
95 {
96 return Err(too_big(len));
97 }
98 let mut buf: Vec<u8> = Vec::new();
99 while let Some(chunk) = resp.chunk().await? {
100 if chunk.len() > max.saturating_sub(buf.len()) {
101 return Err(too_big(buf.len().saturating_add(chunk.len()) as u64));
102 }
103 buf.extend_from_slice(&chunk);
104 }
105 Ok(buf)
106}
107
108#[derive(
110 Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize,
111)]
112#[serde(rename_all = "lowercase")]
113pub enum VendorId {
114 Anthropic,
115 #[serde(rename = "anthropic_api")]
116 AnthropicApi,
117 Openai,
118 Copilot,
119 Zai,
120 Openrouter,
121 Deepseek,
122 Kimi,
123 Kilo,
124 Novita,
125 Moonshot,
126 Grok,
127 Supergrok,
128 Antigravity,
129 Cursor,
130 Minimax,
131 Kiro,
132 #[serde(rename = "nous")]
133 NousResearch,
134 #[serde(rename = "opencode-go")]
135 OpenCodeGo,
136 #[serde(rename = "commandcode")]
137 CommandCode,
138}
139
140impl VendorId {
141 pub fn slug(self) -> &'static str {
142 match self {
143 VendorId::Anthropic => "anthropic",
144 VendorId::AnthropicApi => "anthropic_api",
145 VendorId::Openai => "openai",
146 VendorId::Copilot => "copilot",
147 VendorId::Zai => "zai",
148 VendorId::Openrouter => "openrouter",
149 VendorId::Deepseek => "deepseek",
150 VendorId::Kimi => "kimi",
151 VendorId::Kilo => "kilo",
152 VendorId::Novita => "novita",
153 VendorId::Moonshot => "moonshot",
154 VendorId::Grok => "grok",
155 VendorId::Supergrok => "supergrok",
156 VendorId::Antigravity => "antigravity",
157 VendorId::Cursor => "cursor",
158 VendorId::Minimax => "minimax",
159 VendorId::Kiro => "kiro",
160 VendorId::NousResearch => "nous",
161 VendorId::OpenCodeGo => "opencode-go",
162 VendorId::CommandCode => "commandcode",
163 }
164 }
165
166 pub fn display_name(self) -> &'static str {
170 match self {
171 VendorId::Anthropic => "Claude",
172 VendorId::AnthropicApi => "Anthropic API",
173 VendorId::Openai => "Codex",
174 VendorId::Copilot => "GitHub Copilot",
175 VendorId::Zai => "Z.AI",
176 VendorId::Openrouter => "OpenRouter",
177 VendorId::Deepseek => "DeepSeek",
178 VendorId::Kimi => "Kimi",
179 VendorId::Kilo => "Kilo",
180 VendorId::Novita => "Novita",
181 VendorId::Moonshot => "Moonshot",
182 VendorId::Grok => "Grok",
183 VendorId::Supergrok => "SuperGrok",
184 VendorId::Antigravity => "Antigravity",
185 VendorId::Cursor => "Cursor",
186 VendorId::Minimax => "MiniMax",
187 VendorId::Kiro => "Kiro",
188 VendorId::NousResearch => "Nous Research",
189 VendorId::OpenCodeGo => "OpenCode Go",
190 VendorId::CommandCode => "Command Code",
191 }
192 }
193
194 pub const fn short_name(self) -> &'static str {
199 match self {
200 VendorId::Anthropic => "cld",
201 VendorId::AnthropicApi => "aac",
202 VendorId::Openai => "gpt",
203 VendorId::Copilot => "ghc",
204 VendorId::Zai => "zai",
205 VendorId::Openrouter => "opr",
206 VendorId::Deepseek => "dsk",
207 VendorId::Kimi => "kmi",
208 VendorId::Kilo => "klo",
209 VendorId::Novita => "nvt",
210 VendorId::Moonshot => "msh",
211 VendorId::Grok => "grk",
212 VendorId::Supergrok => "sgk",
213 VendorId::Antigravity => "agy",
214 VendorId::Cursor => "cur",
215 VendorId::Minimax => "mmx",
216 VendorId::Kiro => "kir",
217 VendorId::NousResearch => "nrs",
218 VendorId::OpenCodeGo => "ocg",
219 VendorId::CommandCode => "cmc",
220 }
221 }
222
223 pub fn all() -> &'static [VendorId] {
224 &[
225 VendorId::Anthropic,
226 VendorId::AnthropicApi,
227 VendorId::Openai,
228 VendorId::Copilot,
229 VendorId::Zai,
230 VendorId::Openrouter,
231 VendorId::Deepseek,
232 VendorId::Kimi,
233 VendorId::Kilo,
234 VendorId::Novita,
235 VendorId::Moonshot,
236 VendorId::Grok,
237 VendorId::Supergrok,
238 VendorId::Antigravity,
239 VendorId::Cursor,
240 VendorId::Minimax,
241 VendorId::Kiro,
242 VendorId::NousResearch,
243 VendorId::OpenCodeGo,
244 VendorId::CommandCode,
245 ]
246 }
247}
248
249pub type VendorOutcome = crate::outcome::Outcome<VendorSnapshot>;
254
255#[derive(Debug, Clone)]
257pub struct RenderOpts {
258 pub format: Option<String>,
259 pub tooltip_format: Option<String>,
260 pub icon: Option<String>,
261 pub pace_tolerance: u32,
262 pub format_pace_color: bool,
263 pub tooltip_pace_pts: bool,
264}
265
266impl RenderOpts {
267 pub fn from_cli(cli: &Cli) -> Self {
268 Self {
269 format: cli.format.clone(),
270 tooltip_format: cli.tooltip_format.clone(),
271 icon: cli.icon.clone(),
272 pace_tolerance: cli.pace_tolerance,
273 format_pace_color: cli.format_pace_color,
274 tooltip_pace_pts: cli.tooltip_pace_pts,
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn every_vendor_has_stable_machine_and_display_names() {
285 for vendor in VendorId::all() {
286 assert!(!vendor.slug().is_empty());
287 assert!(!vendor.display_name().is_empty());
288 }
289 assert_eq!(VendorId::Anthropic.slug(), "anthropic");
290 assert_eq!(VendorId::Anthropic.display_name(), "Claude");
291 assert_eq!(VendorId::Openai.display_name(), "Codex");
292 assert_eq!(VendorId::Zai.display_name(), "Z.AI");
293 }
294
295 #[test]
299 fn every_vendor_short_name_is_a_unique_three_letter_code() {
300 let mut seen = std::collections::BTreeSet::new();
301 for vendor in VendorId::all() {
302 let short = vendor.short_name();
303 assert_eq!(short.len(), 3, "{} is not three letters", vendor.slug());
304 assert!(
305 short.chars().all(|c| c.is_ascii_lowercase()),
306 "{} is not lowercase ascii",
307 vendor.slug()
308 );
309 assert!(seen.insert(short), "{short} is used by two vendors");
310 }
311 assert_eq!(VendorId::Anthropic.short_name(), "cld");
312 assert_eq!(VendorId::Openai.short_name(), "gpt");
313 assert_eq!(VendorId::Zai.short_name(), "zai");
314 assert_eq!(VendorId::Antigravity.short_name(), "agy");
315 }
316
317 #[test]
318 fn new_vendor_contracts_keep_public_names_and_slugs() {
319 assert_eq!(VendorId::NousResearch.slug(), "nous");
320 assert_eq!(VendorId::NousResearch.display_name(), "Nous Research");
321 assert_eq!(VendorId::OpenCodeGo.slug(), "opencode-go");
322 assert_eq!(VendorId::OpenCodeGo.display_name(), "OpenCode Go");
323 assert_eq!(
324 serde_json::to_value(VendorId::OpenCodeGo).unwrap(),
325 serde_json::json!("opencode-go")
326 );
327 }
328
329 #[test]
330 fn vendor_secret_env_vars_cover_config_defaults() {
331 let configured_defaults = [
332 "ZAI_API_KEY",
333 "OPENROUTER_API_KEY",
334 "DEEPSEEK_API_KEY",
335 "KIMI_API_KEY",
336 "KILO_API_KEY",
337 "NOVITA_API_KEY",
338 "MINIMAX_API_KEY",
339 "MOONSHOT_API_KEY",
340 "XAI_MANAGEMENT_KEY",
341 "ANTHROPIC_ADMIN_KEY",
342 "GITHUB_COPILOT_TOKEN",
343 ];
344 for name in configured_defaults {
345 assert!(VENDOR_SECRET_ENV_VARS.contains(&name), "missing {name}");
346 }
347 }
348
349 #[test]
350 fn vars_to_remove_preserves_only_requested_grok_credentials() {
351 let removed = vendor_secret_env_vars_to_remove(&["XAI_API_KEY", "GROK_API_KEY"]);
352 assert!(!removed.contains(&"XAI_API_KEY"));
353 assert!(!removed.contains(&"GROK_API_KEY"));
354 assert!(removed.contains(&"ANTHROPIC_ADMIN_KEY"));
355 assert!(removed.contains(&"OPENROUTER_API_KEY"));
356 assert_eq!(removed.len(), VENDOR_SECRET_ENV_VARS.len() - 2);
357 }
358
359 #[test]
360 fn copilot_token_is_removed_before_unrelated_subprocesses_launch() {
361 let removed = vendor_secret_env_vars_to_remove(&[]);
362 assert!(removed.contains(&"GITHUB_COPILOT_TOKEN"));
363 }
364
365 #[tokio::test]
366 async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
367 let mut server = mockito::Server::new_async().await;
368 server
369 .mock("GET", "/big")
370 .with_status(200)
371 .with_body("x".repeat(4096))
372 .create_async()
373 .await;
374 server
375 .mock("GET", "/small")
376 .with_status(200)
377 .with_body("hello")
378 .create_async()
379 .await;
380
381 let client = reqwest::Client::new();
382
383 let resp = client
385 .get(format!("{}/big", server.url()))
386 .send()
387 .await
388 .unwrap();
389 let err = read_body_capped(resp, 1024).await.unwrap_err();
390 assert!(
391 err.to_string().contains("exceeds"),
392 "unexpected error: {err}"
393 );
394
395 let resp = client
397 .get(format!("{}/small", server.url()))
398 .send()
399 .await
400 .unwrap();
401 assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
402 }
403
404 #[tokio::test]
405 async fn chunked_body_without_content_length_still_hits_the_cap() {
406 let mut server = mockito::Server::new_async().await;
407 server
408 .mock("GET", "/chunked")
409 .with_status(200)
410 .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
411 .create_async()
412 .await;
413
414 let response = reqwest::Client::new()
415 .get(format!("{}/chunked", server.url()))
416 .send()
417 .await
418 .unwrap();
419 assert!(response.content_length().is_none());
420 let error = read_body_capped(response, 1024).await.unwrap_err();
421 assert!(error.to_string().contains("exceeds"), "{error}");
422 }
423
424 #[tokio::test]
425 async fn same_origin_redirects_still_work_with_vendor_headers() {
426 let mut server = mockito::Server::new_async().await;
427 let redirect = server
428 .mock("GET", "/start")
429 .match_header("x-api-key", "secret")
430 .with_status(302)
431 .with_header("location", "/finish")
432 .create_async()
433 .await;
434 let finish = server
435 .mock("GET", "/finish")
436 .match_header("x-api-key", "secret")
437 .with_status(200)
438 .create_async()
439 .await;
440 let client = reqwest::Client::builder()
441 .redirect(same_origin_redirect_policy())
442 .build()
443 .unwrap();
444
445 let response = client
446 .get(format!("{}/start", server.url()))
447 .header("x-api-key", "secret")
448 .send()
449 .await
450 .unwrap();
451
452 assert_eq!(response.status(), reqwest::StatusCode::OK);
453 redirect.assert_async().await;
454 finish.assert_async().await;
455 }
456
457 #[tokio::test]
458 async fn cross_origin_redirects_are_not_followed_with_vendor_headers() {
459 let mut origin = mockito::Server::new_async().await;
460 let mut target = mockito::Server::new_async().await;
461 let target_url = format!("{}/capture", target.url());
462 let redirect = origin
463 .mock("GET", "/start")
464 .match_header("x-api-key", "secret")
465 .with_status(302)
466 .with_header("location", &target_url)
467 .create_async()
468 .await;
469 let capture = target
470 .mock("GET", "/capture")
471 .expect(0)
472 .create_async()
473 .await;
474 let client = reqwest::Client::builder()
475 .redirect(same_origin_redirect_policy())
476 .build()
477 .unwrap();
478
479 let response = client
480 .get(format!("{}/start", origin.url()))
481 .header("x-api-key", "secret")
482 .send()
483 .await
484 .unwrap();
485
486 assert_eq!(response.status(), reqwest::StatusCode::FOUND);
487 redirect.assert_async().await;
488 capture.assert_async().await;
489 }
490
491 #[test]
492 fn vendor_id_slug_round_trip() {
493 for id in VendorId::all() {
494 assert_eq!(
495 id.slug(),
496 serde_json::to_value(id).unwrap().as_str().unwrap()
497 );
498 }
499 }
500}