Skip to main content

usage_monitor_cli/provider/
grok.rs

1//! Grok usage provider.
2//!
3//! Ports CodexBar's `GetGrokCreditsConfig` call on Grok's gRPC-Web billing
4//! service. The request is an empty gRPC-Web frame and the response is a
5//! protobuf message whose exact schema is not published, so — like CodexBar —
6//! this module generically scans the protobuf for the credit-usage percentage
7//! (a `float`/fixed32 ending in field 1, in `0..=100`) and the quota reset
8//! timestamp (a unix-seconds varint, preferring path `1.5.1`).
9
10use 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    /// (path, value, order) for fixed32 (float) fields.
23    fixed32: Vec<(Vec<u32>, f32, usize)>,
24    /// (path, value) for varint fields.
25    varint: Vec<(Vec<u32>, u64)>,
26}
27
28/// Recursively scans a protobuf message, recording fixed32 and varint fields
29/// with their nested field-number path (depth-limited, like CodexBar).
30fn 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
68/// Splits a gRPC-Web body into its data-frame payloads (flag bit 0x80 clear).
69fn 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
90/// gRPC status from trailer frames (flag bit 0x80 set), `None` if unset (== OK).
91fn 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
128/// Grok usage provider (xAI Bearer token or grok.com cookie auth).
129pub 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    /// Resolves `(authorization, cookie)` — at least one is required.
170    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    /// Extracts the credit-usage percentage and reset from the protobuf payloads.
219    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        // Usage percent: a float field whose path ends in field 1, in 0..=100.
230        // Prefer the shallowest path, then the earliest-seen value.
231        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        // Reset: a future unix-seconds varint, preferring path 1.5.1.
241        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        // A fresh billing period can report no usage yet (no float, but a reset
261        // and a usage-period marker) — treat that as 0% used.
262        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            // Empty gRPC-Web frame: 1 flag byte + 4-byte length (0).
343            .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    /// Encodes a fixed32 (float) field.
385    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    /// Wraps `inner` as a length-delimited field.
391    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    /// Wraps a protobuf payload in a gRPC-Web data frame.
398    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        // message { 1: { 1: float 42.5 } } → path [1,1] ends in field 1.
437        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)); // 42.5 rounds to 43
444    }
445
446    #[test]
447    fn test_no_usage_yet_zero_percent() {
448        // No float, but a future reset at path 1.5.1 and a usage-period marker
449        // at 1.6.x → 0% used.
450        let future = (Utc::now().timestamp() + 86_400) as u64;
451        // 1 -> { 5 -> { 1: varint reset }, 6 -> { 1: varint 1 } }
452        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}