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