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 fn all() -> &'static [VendorId] {
184 &[
185 VendorId::Anthropic,
186 VendorId::AnthropicApi,
187 VendorId::Openai,
188 VendorId::Zai,
189 VendorId::Openrouter,
190 VendorId::Deepseek,
191 VendorId::Kimi,
192 VendorId::Kilo,
193 VendorId::Novita,
194 VendorId::Moonshot,
195 VendorId::Grok,
196 VendorId::Supergrok,
197 VendorId::Antigravity,
198 VendorId::Cursor,
199 VendorId::Minimax,
200 VendorId::Kiro,
201 VendorId::NousResearch,
202 VendorId::OpenCodeGo,
203 ]
204 }
205}
206
207#[derive(Debug, Clone)]
210pub struct VendorOutcome {
211 pub snapshot: VendorSnapshot,
212 pub stale: bool,
213 pub last_error: Option<(u16, String)>,
214 pub cache_age: Option<std::time::Duration>,
215}
216
217#[derive(Debug, Clone)]
219pub struct RenderOpts {
220 pub format: Option<String>,
221 pub tooltip_format: Option<String>,
222 pub icon: Option<String>,
223 pub pace_tolerance: u32,
224 pub format_pace_color: bool,
225 pub tooltip_pace_pts: bool,
226}
227
228impl RenderOpts {
229 pub fn from_cli(cli: &Cli) -> Self {
230 Self {
231 format: cli.format.clone(),
232 tooltip_format: cli.tooltip_format.clone(),
233 icon: cli.icon.clone(),
234 pace_tolerance: cli.pace_tolerance,
235 format_pace_color: cli.format_pace_color,
236 tooltip_pace_pts: cli.tooltip_pace_pts,
237 }
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn every_vendor_has_stable_machine_and_display_names() {
247 for vendor in VendorId::all() {
248 assert!(!vendor.slug().is_empty());
249 assert!(!vendor.display_name().is_empty());
250 }
251 assert_eq!(VendorId::Anthropic.slug(), "anthropic");
252 assert_eq!(VendorId::Anthropic.display_name(), "Claude");
253 assert_eq!(VendorId::Openai.display_name(), "Codex");
254 assert_eq!(VendorId::Zai.display_name(), "Z.AI");
255 }
256
257 #[test]
258 fn new_vendor_contracts_keep_public_names_and_slugs() {
259 assert_eq!(VendorId::NousResearch.slug(), "nous");
260 assert_eq!(VendorId::NousResearch.display_name(), "Nous Research");
261 assert_eq!(VendorId::OpenCodeGo.slug(), "opencode-go");
262 assert_eq!(VendorId::OpenCodeGo.display_name(), "OpenCode Go");
263 assert_eq!(
264 serde_json::to_value(VendorId::OpenCodeGo).unwrap(),
265 serde_json::json!("opencode-go")
266 );
267 }
268
269 #[test]
270 fn vendor_secret_env_vars_cover_config_defaults() {
271 let configured_defaults = [
272 "ZAI_API_KEY",
273 "OPENROUTER_API_KEY",
274 "DEEPSEEK_API_KEY",
275 "KIMI_API_KEY",
276 "KILO_API_KEY",
277 "NOVITA_API_KEY",
278 "MINIMAX_API_KEY",
279 "MOONSHOT_API_KEY",
280 "XAI_MANAGEMENT_KEY",
281 "ANTHROPIC_ADMIN_KEY",
282 ];
283 for name in configured_defaults {
284 assert!(VENDOR_SECRET_ENV_VARS.contains(&name), "missing {name}");
285 }
286 }
287
288 #[test]
289 fn vars_to_remove_preserves_only_requested_grok_credentials() {
290 let removed = vendor_secret_env_vars_to_remove(&["XAI_API_KEY", "GROK_API_KEY"]);
291 assert!(!removed.contains(&"XAI_API_KEY"));
292 assert!(!removed.contains(&"GROK_API_KEY"));
293 assert!(removed.contains(&"ANTHROPIC_ADMIN_KEY"));
294 assert!(removed.contains(&"OPENROUTER_API_KEY"));
295 assert_eq!(removed.len(), VENDOR_SECRET_ENV_VARS.len() - 2);
296 }
297
298 #[tokio::test]
299 async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
300 let mut server = mockito::Server::new_async().await;
301 server
302 .mock("GET", "/big")
303 .with_status(200)
304 .with_body("x".repeat(4096))
305 .create_async()
306 .await;
307 server
308 .mock("GET", "/small")
309 .with_status(200)
310 .with_body("hello")
311 .create_async()
312 .await;
313
314 let client = reqwest::Client::new();
315
316 let resp = client
318 .get(format!("{}/big", server.url()))
319 .send()
320 .await
321 .unwrap();
322 let err = read_body_capped(resp, 1024).await.unwrap_err();
323 assert!(
324 err.to_string().contains("exceeds"),
325 "unexpected error: {err}"
326 );
327
328 let resp = client
330 .get(format!("{}/small", server.url()))
331 .send()
332 .await
333 .unwrap();
334 assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
335 }
336
337 #[tokio::test]
338 async fn chunked_body_without_content_length_still_hits_the_cap() {
339 let mut server = mockito::Server::new_async().await;
340 server
341 .mock("GET", "/chunked")
342 .with_status(200)
343 .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
344 .create_async()
345 .await;
346
347 let response = reqwest::Client::new()
348 .get(format!("{}/chunked", server.url()))
349 .send()
350 .await
351 .unwrap();
352 assert!(response.content_length().is_none());
353 let error = read_body_capped(response, 1024).await.unwrap_err();
354 assert!(error.to_string().contains("exceeds"), "{error}");
355 }
356
357 #[tokio::test]
358 async fn same_origin_redirects_still_work_with_vendor_headers() {
359 let mut server = mockito::Server::new_async().await;
360 let redirect = server
361 .mock("GET", "/start")
362 .match_header("x-api-key", "secret")
363 .with_status(302)
364 .with_header("location", "/finish")
365 .create_async()
366 .await;
367 let finish = server
368 .mock("GET", "/finish")
369 .match_header("x-api-key", "secret")
370 .with_status(200)
371 .create_async()
372 .await;
373 let client = reqwest::Client::builder()
374 .redirect(same_origin_redirect_policy())
375 .build()
376 .unwrap();
377
378 let response = client
379 .get(format!("{}/start", server.url()))
380 .header("x-api-key", "secret")
381 .send()
382 .await
383 .unwrap();
384
385 assert_eq!(response.status(), reqwest::StatusCode::OK);
386 redirect.assert_async().await;
387 finish.assert_async().await;
388 }
389
390 #[tokio::test]
391 async fn cross_origin_redirects_are_not_followed_with_vendor_headers() {
392 let mut origin = mockito::Server::new_async().await;
393 let mut target = mockito::Server::new_async().await;
394 let target_url = format!("{}/capture", target.url());
395 let redirect = origin
396 .mock("GET", "/start")
397 .match_header("x-api-key", "secret")
398 .with_status(302)
399 .with_header("location", &target_url)
400 .create_async()
401 .await;
402 let capture = target
403 .mock("GET", "/capture")
404 .expect(0)
405 .create_async()
406 .await;
407 let client = reqwest::Client::builder()
408 .redirect(same_origin_redirect_policy())
409 .build()
410 .unwrap();
411
412 let response = client
413 .get(format!("{}/start", origin.url()))
414 .header("x-api-key", "secret")
415 .send()
416 .await
417 .unwrap();
418
419 assert_eq!(response.status(), reqwest::StatusCode::FOUND);
420 redirect.assert_async().await;
421 capture.assert_async().await;
422 }
423
424 #[test]
425 fn vendor_id_slug_round_trip() {
426 for id in VendorId::all() {
427 assert_eq!(
428 id.slug(),
429 serde_json::to_value(id).unwrap().as_str().unwrap()
430 );
431 }
432 }
433}