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 async fn read_body_capped(
31 mut resp: reqwest::Response,
32 max: usize,
33) -> crate::error::Result<Vec<u8>> {
34 let too_big = |n: u64| {
35 crate::error::AppError::Schema(format!(
36 "response body exceeds the {max}-byte limit ({n} bytes); refusing to buffer it"
37 ))
38 };
39 if let Some(len) = resp.content_length()
40 && len > max as u64
41 {
42 return Err(too_big(len));
43 }
44 let mut buf: Vec<u8> = Vec::new();
45 while let Some(chunk) = resp.chunk().await? {
46 if chunk.len() > max.saturating_sub(buf.len()) {
47 return Err(too_big(buf.len().saturating_add(chunk.len()) as u64));
48 }
49 buf.extend_from_slice(&chunk);
50 }
51 Ok(buf)
52}
53
54#[derive(
56 Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize,
57)]
58#[serde(rename_all = "lowercase")]
59pub enum VendorId {
60 Anthropic,
61 #[serde(rename = "anthropic_api")]
62 AnthropicApi,
63 Openai,
64 Zai,
65 Openrouter,
66 Deepseek,
67 Kimi,
68 Kilo,
69 Novita,
70 Moonshot,
71 Grok,
72 Antigravity,
73 Cursor,
74}
75
76impl VendorId {
77 pub fn slug(self) -> &'static str {
78 match self {
79 VendorId::Anthropic => "anthropic",
80 VendorId::AnthropicApi => "anthropic_api",
81 VendorId::Openai => "openai",
82 VendorId::Zai => "zai",
83 VendorId::Openrouter => "openrouter",
84 VendorId::Deepseek => "deepseek",
85 VendorId::Kimi => "kimi",
86 VendorId::Kilo => "kilo",
87 VendorId::Novita => "novita",
88 VendorId::Moonshot => "moonshot",
89 VendorId::Grok => "grok",
90 VendorId::Antigravity => "antigravity",
91 VendorId::Cursor => "cursor",
92 }
93 }
94
95 pub fn all() -> &'static [VendorId] {
96 &[
97 VendorId::Anthropic,
98 VendorId::AnthropicApi,
99 VendorId::Openai,
100 VendorId::Zai,
101 VendorId::Openrouter,
102 VendorId::Deepseek,
103 VendorId::Kimi,
104 VendorId::Kilo,
105 VendorId::Novita,
106 VendorId::Moonshot,
107 VendorId::Grok,
108 VendorId::Antigravity,
109 VendorId::Cursor,
110 ]
111 }
112}
113
114#[derive(Debug, Clone)]
117pub struct VendorOutcome {
118 pub snapshot: VendorSnapshot,
119 pub stale: bool,
120 pub last_error: Option<(u16, String)>,
121 pub cache_age: Option<std::time::Duration>,
122}
123
124#[derive(Debug, Clone)]
126pub struct RenderOpts {
127 pub format: Option<String>,
128 pub tooltip_format: Option<String>,
129 pub icon: Option<String>,
130 pub pace_tolerance: u32,
131 pub format_pace_color: bool,
132 pub tooltip_pace_pts: bool,
133}
134
135impl RenderOpts {
136 pub fn from_cli(cli: &Cli) -> Self {
137 Self {
138 format: cli.format.clone(),
139 tooltip_format: cli.tooltip_format.clone(),
140 icon: cli.icon.clone(),
141 pace_tolerance: cli.pace_tolerance,
142 format_pace_color: cli.format_pace_color,
143 tooltip_pace_pts: cli.tooltip_pace_pts,
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[tokio::test]
153 async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
154 let mut server = mockito::Server::new_async().await;
155 server
156 .mock("GET", "/big")
157 .with_status(200)
158 .with_body("x".repeat(4096))
159 .create_async()
160 .await;
161 server
162 .mock("GET", "/small")
163 .with_status(200)
164 .with_body("hello")
165 .create_async()
166 .await;
167
168 let client = reqwest::Client::new();
169
170 let resp = client
172 .get(format!("{}/big", server.url()))
173 .send()
174 .await
175 .unwrap();
176 let err = read_body_capped(resp, 1024).await.unwrap_err();
177 assert!(
178 err.to_string().contains("exceeds"),
179 "unexpected error: {err}"
180 );
181
182 let resp = client
184 .get(format!("{}/small", server.url()))
185 .send()
186 .await
187 .unwrap();
188 assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
189 }
190
191 #[tokio::test]
192 async fn chunked_body_without_content_length_still_hits_the_cap() {
193 let mut server = mockito::Server::new_async().await;
194 server
195 .mock("GET", "/chunked")
196 .with_status(200)
197 .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
198 .create_async()
199 .await;
200
201 let response = reqwest::Client::new()
202 .get(format!("{}/chunked", server.url()))
203 .send()
204 .await
205 .unwrap();
206 assert!(response.content_length().is_none());
207 let error = read_body_capped(response, 1024).await.unwrap_err();
208 assert!(error.to_string().contains("exceeds"), "{error}");
209 }
210
211 #[test]
212 fn vendor_id_slug_round_trip() {
213 for id in VendorId::all() {
214 assert_eq!(
215 id.slug(),
216 serde_json::to_value(id).unwrap().as_str().unwrap()
217 );
218 }
219 }
220}