1use async_trait::async_trait;
11use chrono::{DateTime, TimeZone, Utc};
12
13use crate::error::SpendPanelError;
14use crate::model::{RateWindow, UsageSnapshot};
15use crate::provider::proto::{Reader, WIRE_FIXED32, WIRE_FIXED64, WIRE_LEN, WIRE_VARINT};
16use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
17
18const ENDPOINT_PATH: &str = "/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
19
20#[derive(Debug, Default)]
21struct Scan {
22 fixed32: Vec<(Vec<u32>, f32, usize)>,
24 varint: Vec<(Vec<u32>, u64)>,
26}
27
28fn scan_protobuf(data: &[u8], path: &[u32], depth: u8, order: &mut usize, scan: &mut Scan) {
31 let mut reader = Reader::new(data);
32 while let Some((field, wire)) = reader.next_key() {
33 let mut field_path = path.to_vec();
34 field_path.push(field);
35 match wire {
36 WIRE_VARINT => {
37 let Some(v) = reader.read_varint() else {
38 return;
39 };
40 scan.varint.push((field_path, v));
41 }
42 WIRE_FIXED64 => {
43 if reader.read_fixed64().is_none() {
44 return;
45 }
46 }
47 WIRE_LEN => {
48 let Some(inner) = reader.read_len() else {
49 return;
50 };
51 if depth < 4 {
52 scan_protobuf(inner, &field_path, depth + 1, order, scan);
53 }
54 }
55 WIRE_FIXED32 => {
56 let Some(bits) = reader.read_fixed32() else {
57 return;
58 };
59 scan.fixed32
60 .push((field_path, f32::from_bits(bits), *order));
61 *order += 1;
62 }
63 _ => return,
64 }
65 }
66}
67
68fn grpc_web_data_frames(data: &[u8]) -> Vec<&[u8]> {
70 let mut frames = Vec::new();
71 let mut i = 0;
72 while i + 5 <= data.len() {
73 let flags = data[i];
74 let len = u32::from_be_bytes([data[i + 1], data[i + 2], data[i + 3], data[i + 4]]) as usize;
75 let start = i + 5;
76 let Some(end) = start.checked_add(len) else {
77 return Vec::new();
78 };
79 if end > data.len() {
80 return Vec::new();
81 }
82 if flags & 0x80 == 0 {
83 frames.push(&data[start..end]);
84 }
85 i = end;
86 }
87 frames
88}
89
90fn grpc_web_trailer_status(data: &[u8]) -> Option<i64> {
92 let mut i = 0;
93 while i + 5 <= data.len() {
94 let flags = data[i];
95 let len = u32::from_be_bytes([data[i + 1], data[i + 2], data[i + 3], data[i + 4]]) as usize;
96 let start = i + 5;
97 let end = start.checked_add(len)?;
98 if end > data.len() {
99 break;
100 }
101 let trailer = (flags & 0x80 != 0)
102 .then(|| std::str::from_utf8(&data[start..end]).ok())
103 .flatten();
104 if let Some(text) = trailer {
105 for line in text.split(['\r', '\n']).filter(|l| !l.is_empty()) {
106 if let Some((_, value)) = line
107 .split_once(':')
108 .filter(|(k, _)| k.trim().eq_ignore_ascii_case("grpc-status"))
109 {
110 return value.trim().parse::<i64>().ok();
111 }
112 }
113 }
114 i = end;
115 }
116 None
117}
118
119fn looks_like_protobuf(data: &[u8]) -> bool {
120 let Some(&first) = data.first() else {
121 return false;
122 };
123 let field = first >> 3;
124 let wire = first & 0x07;
125 field > 0 && matches!(wire, 0 | 1 | 2 | 5)
126}
127
128pub struct GrokProvider {
130 metadata: ProviderMetadata,
131 base_url: Option<String>,
132}
133
134impl GrokProvider {
135 pub fn new() -> Self {
136 Self {
137 metadata: ProviderMetadata {
138 id: "grok",
139 name: "Grok",
140 description: "Grok credit-usage monitor (gRPC-Web billing)",
141 auth_methods: &["token", "cookie", "env"],
142 website: Some("https://grok.com"),
143 },
144 base_url: None,
145 }
146 }
147
148 pub fn with_base_url(url: &str) -> Self {
149 let mut p = Self::new();
150 p.base_url = Some(url.to_string());
151 p
152 }
153
154 fn api_base(&self) -> &str {
155 self.base_url.as_deref().unwrap_or("https://grok.com")
156 }
157
158 fn clean(raw: &str) -> String {
159 let mut v = raw.trim();
160 if v.len() >= 2
161 && ((v.starts_with('"') && v.ends_with('"'))
162 || (v.starts_with('\'') && v.ends_with('\'')))
163 {
164 v = &v[1..v.len() - 1];
165 }
166 v.trim().to_string()
167 }
168
169 fn resolve_auth(
171 ctx: &ProviderContext,
172 ) -> Result<(Option<String>, Option<String>), SpendPanelError> {
173 let token = ["token", "access_token", "api_key"]
174 .iter()
175 .find_map(|k| {
176 ctx.config
177 .get(*k)
178 .map(|v| Self::clean(v))
179 .filter(|c| !c.is_empty())
180 })
181 .or_else(|| {
182 ["GROK_TOKEN", "GROK_ACCESS_TOKEN"].iter().find_map(|e| {
183 std::env::var(e)
184 .ok()
185 .map(|v| Self::clean(&v))
186 .filter(|c| !c.is_empty())
187 })
188 });
189 let cookie = ctx
190 .config
191 .get("cookie")
192 .map(|v| Self::clean(v))
193 .filter(|c| !c.is_empty())
194 .or_else(|| {
195 std::env::var("GROK_COOKIE")
196 .ok()
197 .map(|v| Self::clean(&v))
198 .filter(|c| !c.is_empty())
199 });
200
201 if token.is_none() && cookie.is_none() {
202 return Err(SpendPanelError::AuthFailed(
203 "grok".into(),
204 "no Bearer token or cookie in config (token/cookie) or GROK_TOKEN/GROK_COOKIE"
205 .into(),
206 ));
207 }
208 Ok((token.map(|t| format!("Bearer {}", t)), cookie))
209 }
210
211 fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
212 reqwest::Client::builder()
213 .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
214 .build()
215 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
216 }
217
218 fn parse_payloads(
220 payloads: &[&[u8]],
221 now: DateTime<Utc>,
222 ) -> Result<UsageSnapshot, SpendPanelError> {
223 let mut scan = Scan::default();
224 let mut order = 0usize;
225 for payload in payloads {
226 scan_protobuf(payload, &[], 0, &mut order, &mut scan);
227 }
228
229 let parsed_percent = scan
232 .fixed32
233 .iter()
234 .filter(|(path, v, _)| {
235 path.last() == Some(&1) && v.is_finite() && *v >= 0.0 && *v <= 100.0
236 })
237 .min_by(|a, b| a.0.len().cmp(&b.0.len()).then(a.2.cmp(&b.2)))
238 .map(|(_, v, _)| *v as f64);
239
240 let now_ts = now.timestamp() as u64;
242 let resets: Vec<(&Vec<u32>, DateTime<Utc>)> = scan
243 .varint
244 .iter()
245 .filter(|(_, raw)| *raw >= 1_700_000_000 && *raw <= 2_100_000_000)
246 .filter_map(|(path, raw)| {
247 Utc.timestamp_opt(*raw as i64, 0)
248 .single()
249 .map(|d| (path, d))
250 })
251 .filter(|(_, d)| d.timestamp() as u64 > now_ts)
252 .collect();
253 let preferred_reset = resets
254 .iter()
255 .filter(|(path, _)| path.as_slice() == [1, 5, 1])
256 .map(|(_, d)| *d)
257 .min();
258 let reset = preferred_reset.or_else(|| resets.iter().map(|(_, d)| *d).min());
259
260 let has_usage_period = scan.varint.iter().any(|(path, value)| {
263 path.starts_with(&[1, 6])
264 || (path.as_slice() == [1, 8, 1] && (*value == 1 || *value == 2))
265 });
266 let no_usage_yet = parsed_percent.is_none()
267 && scan.fixed32.is_empty()
268 && reset.is_some()
269 && has_usage_period;
270
271 let percent = parsed_percent
272 .or(if no_usage_yet { Some(0.0) } else { None })
273 .ok_or_else(|| {
274 SpendPanelError::ParseError(
275 "grok".into(),
276 "no credit usage found in response".into(),
277 )
278 })?;
279
280 let mut snapshot = UsageSnapshot::new("grok");
281 let mut window = RateWindow::new(percent.round() as u64, 100, "Credits", 30 * 24 * 60);
282 window.resets_at = reset;
283 snapshot.primary_rate_window = Some(window);
284 Ok(snapshot)
285 }
286
287 fn parse_response(data: &[u8], now: DateTime<Utc>) -> Result<UsageSnapshot, SpendPanelError> {
288 if let Some(status) = grpc_web_trailer_status(data).filter(|s| *s != 0) {
289 return Err(SpendPanelError::ProviderError(
290 "grok".into(),
291 format!("gRPC status {} (re-authenticate at grok.com)", status),
292 ));
293 }
294 let mut payloads = grpc_web_data_frames(data);
295 if payloads.is_empty() && looks_like_protobuf(data) {
296 payloads = vec![data];
297 }
298 if payloads.is_empty() {
299 return Err(SpendPanelError::ParseError(
300 "grok".into(),
301 "empty gRPC-Web response".into(),
302 ));
303 }
304 Self::parse_payloads(&payloads, now)
305 }
306}
307
308impl Default for GrokProvider {
309 fn default() -> Self {
310 Self::new()
311 }
312}
313
314#[async_trait]
315impl UsageProvider for GrokProvider {
316 fn metadata(&self) -> &ProviderMetadata {
317 &self.metadata
318 }
319
320 fn detect_credentials(&self) -> bool {
321 ["GROK_TOKEN", "GROK_ACCESS_TOKEN", "GROK_COOKIE"]
322 .iter()
323 .any(|e| {
324 std::env::var(e)
325 .map(|v| !v.trim().is_empty())
326 .unwrap_or(false)
327 })
328 }
329
330 async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
331 let (authorization, cookie) = Self::resolve_auth(ctx)?;
332 let client = Self::build_client(ctx)?;
333 let url = format!("{}{}", self.api_base().trim_end_matches('/'), ENDPOINT_PATH);
334
335 let mut req = client
336 .post(url)
337 .header("Content-Type", "application/grpc-web+proto")
338 .header("x-grpc-web", "1")
339 .header("Accept", "*/*")
340 .header("Origin", "https://grok.com")
341 .header("Referer", "https://grok.com/?_s=usage")
342 .body(vec![0u8, 0, 0, 0, 0]);
344 if let Some(auth) = &authorization {
345 req = req.header("Authorization", auth);
346 }
347 if let Some(cookie) = &cookie {
348 req = req.header("Cookie", cookie);
349 }
350
351 let resp = req
352 .send()
353 .await
354 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
355 let status = resp.status();
356 let bytes = resp
357 .bytes()
358 .await
359 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
360 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
361 return Err(SpendPanelError::AuthFailed(
362 "grok".into(),
363 format!("credentials rejected (HTTP {})", status.as_u16()),
364 ));
365 }
366 if !status.is_success() {
367 return Err(SpendPanelError::ProviderError(
368 "grok".into(),
369 format!("HTTP {}", status.as_u16()),
370 ));
371 }
372 Self::parse_response(&bytes, Utc::now())
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::provider::proto::{encode_key, encode_varint};
380 use pretty_assertions::assert_eq;
381 use wiremock::matchers::{method, path};
382 use wiremock::{Mock, MockServer, ResponseTemplate};
383
384 fn float_field(field: u32, value: f32, out: &mut Vec<u8>) {
386 encode_key(field, WIRE_FIXED32, out);
387 out.extend_from_slice(&value.to_bits().to_le_bytes());
388 }
389
390 fn nested(field: u32, inner: &[u8], out: &mut Vec<u8>) {
392 encode_key(field, WIRE_LEN, out);
393 encode_varint(inner.len() as u64, out);
394 out.extend_from_slice(inner);
395 }
396
397 fn data_frame(payload: &[u8]) -> Vec<u8> {
399 let mut frame = vec![0u8];
400 frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
401 frame.extend_from_slice(payload);
402 frame
403 }
404
405 fn trailer_frame(text: &str) -> Vec<u8> {
406 let mut frame = vec![0x80u8];
407 frame.extend_from_slice(&(text.len() as u32).to_be_bytes());
408 frame.extend_from_slice(text.as_bytes());
409 frame
410 }
411
412 #[test]
413 fn test_metadata() {
414 assert_eq!(GrokProvider::new().metadata().id, "grok");
415 }
416
417 #[test]
418 fn test_resolve_auth_missing() {
419 assert!(matches!(
420 GrokProvider::resolve_auth(&ProviderContext::new()).unwrap_err(),
421 SpendPanelError::AuthFailed(_, _)
422 ));
423 }
424
425 #[test]
426 fn test_resolve_auth_bearer() {
427 let mut ctx = ProviderContext::new();
428 ctx.config.insert("token".into(), "xai-key".into());
429 let (auth, cookie) = GrokProvider::resolve_auth(&ctx).unwrap();
430 assert_eq!(auth.as_deref(), Some("Bearer xai-key"));
431 assert!(cookie.is_none());
432 }
433
434 #[test]
435 fn test_scan_finds_percent() {
436 let mut inner = Vec::new();
438 float_field(1, 42.5, &mut inner);
439 let mut msg = Vec::new();
440 nested(1, &inner, &mut msg);
441
442 let snap = GrokProvider::parse_payloads(&[&msg], Utc::now()).unwrap();
443 assert_eq!(snap.primary_rate_window.unwrap().used, Some(43)); }
445
446 #[test]
447 fn test_no_usage_yet_zero_percent() {
448 let future = (Utc::now().timestamp() + 86_400) as u64;
451 let mut f5 = Vec::new();
453 encode_key(1, WIRE_VARINT, &mut f5);
454 encode_varint(future, &mut f5);
455 let mut f6 = Vec::new();
456 encode_key(1, WIRE_VARINT, &mut f6);
457 encode_varint(1, &mut f6);
458 let mut f1 = Vec::new();
459 nested(5, &f5, &mut f1);
460 nested(6, &f6, &mut f1);
461 let mut msg = Vec::new();
462 nested(1, &f1, &mut msg);
463
464 let snap = GrokProvider::parse_payloads(&[&msg], Utc::now()).unwrap();
465 let window = snap.primary_rate_window.unwrap();
466 assert_eq!(window.used, Some(0));
467 assert!(window.resets_at.is_some());
468 }
469
470 #[test]
471 fn test_grpc_web_frame_split() {
472 let payload = vec![0x0d, 0, 0, 0, 0];
473 let mut body = data_frame(&payload);
474 body.extend_from_slice(&trailer_frame("grpc-status:0\r\n"));
475 let frames = grpc_web_data_frames(&body);
476 assert_eq!(frames.len(), 1);
477 assert_eq!(frames[0], &payload[..]);
478 assert_eq!(grpc_web_trailer_status(&body), Some(0));
479 }
480
481 #[test]
482 fn test_parse_response_rejects_grpc_error() {
483 let body = trailer_frame("grpc-status:16\r\ngrpc-message:unauthenticated\r\n");
484 assert!(matches!(
485 GrokProvider::parse_response(&body, Utc::now()).unwrap_err(),
486 SpendPanelError::ProviderError(_, _)
487 ));
488 }
489
490 #[tokio::test]
491 async fn test_fetch_usage_success() {
492 let mut inner = Vec::new();
493 float_field(1, 30.0, &mut inner);
494 let mut msg = Vec::new();
495 nested(1, &inner, &mut msg);
496 let mut body = data_frame(&msg);
497 body.extend_from_slice(&trailer_frame("grpc-status:0\r\n"));
498
499 let server = MockServer::start().await;
500 Mock::given(method("POST"))
501 .and(path(ENDPOINT_PATH))
502 .respond_with(
503 ResponseTemplate::new(200).set_body_raw(body, "application/grpc-web+proto"),
504 )
505 .mount(&server)
506 .await;
507 let provider = GrokProvider::with_base_url(&server.uri());
508 let mut ctx = ProviderContext::new();
509 ctx.config.insert("token".into(), "xai".into());
510 let snap = provider.fetch_usage(&ctx).await.unwrap();
511 assert_eq!(snap.primary_rate_window.unwrap().used, Some(30));
512 }
513
514 #[tokio::test]
515 async fn test_fetch_usage_401() {
516 let server = MockServer::start().await;
517 Mock::given(method("POST"))
518 .and(path(ENDPOINT_PATH))
519 .respond_with(ResponseTemplate::new(401))
520 .mount(&server)
521 .await;
522 let provider = GrokProvider::with_base_url(&server.uri());
523 let mut ctx = ProviderContext::new();
524 ctx.config.insert("token".into(), "bad".into());
525 assert!(matches!(
526 provider.fetch_usage(&ctx).await.unwrap_err(),
527 SpendPanelError::AuthFailed(_, _)
528 ));
529 }
530}