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