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 Minimax,
75}
76
77impl VendorId {
78 pub fn slug(self) -> &'static str {
79 match self {
80 VendorId::Anthropic => "anthropic",
81 VendorId::AnthropicApi => "anthropic_api",
82 VendorId::Openai => "openai",
83 VendorId::Zai => "zai",
84 VendorId::Openrouter => "openrouter",
85 VendorId::Deepseek => "deepseek",
86 VendorId::Kimi => "kimi",
87 VendorId::Kilo => "kilo",
88 VendorId::Novita => "novita",
89 VendorId::Moonshot => "moonshot",
90 VendorId::Grok => "grok",
91 VendorId::Antigravity => "antigravity",
92 VendorId::Cursor => "cursor",
93 VendorId::Minimax => "minimax",
94 }
95 }
96
97 pub fn all() -> &'static [VendorId] {
98 &[
99 VendorId::Anthropic,
100 VendorId::AnthropicApi,
101 VendorId::Openai,
102 VendorId::Zai,
103 VendorId::Openrouter,
104 VendorId::Deepseek,
105 VendorId::Kimi,
106 VendorId::Kilo,
107 VendorId::Novita,
108 VendorId::Moonshot,
109 VendorId::Grok,
110 VendorId::Antigravity,
111 VendorId::Cursor,
112 VendorId::Minimax,
113 ]
114 }
115}
116
117#[derive(Debug, Clone)]
120pub struct VendorOutcome {
121 pub snapshot: VendorSnapshot,
122 pub stale: bool,
123 pub last_error: Option<(u16, String)>,
124 pub cache_age: Option<std::time::Duration>,
125}
126
127#[derive(Debug, Clone)]
129pub struct RenderOpts {
130 pub format: Option<String>,
131 pub tooltip_format: Option<String>,
132 pub icon: Option<String>,
133 pub pace_tolerance: u32,
134 pub format_pace_color: bool,
135 pub tooltip_pace_pts: bool,
136}
137
138impl RenderOpts {
139 pub fn from_cli(cli: &Cli) -> Self {
140 Self {
141 format: cli.format.clone(),
142 tooltip_format: cli.tooltip_format.clone(),
143 icon: cli.icon.clone(),
144 pace_tolerance: cli.pace_tolerance,
145 format_pace_color: cli.format_pace_color,
146 tooltip_pace_pts: cli.tooltip_pace_pts,
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[tokio::test]
156 async fn body_over_the_cap_is_refused_and_under_it_round_trips() {
157 let mut server = mockito::Server::new_async().await;
158 server
159 .mock("GET", "/big")
160 .with_status(200)
161 .with_body("x".repeat(4096))
162 .create_async()
163 .await;
164 server
165 .mock("GET", "/small")
166 .with_status(200)
167 .with_body("hello")
168 .create_async()
169 .await;
170
171 let client = reqwest::Client::new();
172
173 let resp = client
175 .get(format!("{}/big", server.url()))
176 .send()
177 .await
178 .unwrap();
179 let err = read_body_capped(resp, 1024).await.unwrap_err();
180 assert!(
181 err.to_string().contains("exceeds"),
182 "unexpected error: {err}"
183 );
184
185 let resp = client
187 .get(format!("{}/small", server.url()))
188 .send()
189 .await
190 .unwrap();
191 assert_eq!(read_body_capped(resp, 1024).await.unwrap(), b"hello");
192 }
193
194 #[tokio::test]
195 async fn chunked_body_without_content_length_still_hits_the_cap() {
196 let mut server = mockito::Server::new_async().await;
197 server
198 .mock("GET", "/chunked")
199 .with_status(200)
200 .with_chunked_body(|writer| writer.write_all(&[b'x'; 4096]))
201 .create_async()
202 .await;
203
204 let response = reqwest::Client::new()
205 .get(format!("{}/chunked", server.url()))
206 .send()
207 .await
208 .unwrap();
209 assert!(response.content_length().is_none());
210 let error = read_body_capped(response, 1024).await.unwrap_err();
211 assert!(error.to_string().contains("exceeds"), "{error}");
212 }
213
214 #[test]
215 fn vendor_id_slug_round_trip() {
216 for id in VendorId::all() {
217 assert_eq!(
218 id.slug(),
219 serde_json::to_value(id).unwrap().as_str().unwrap()
220 );
221 }
222 }
223}