Skip to main content

ai_usagebar/nous/
types.rs

1//! Strict, non-secret wire models for the Nous Research OAuth/account APIs.
2//!
3//! The wire payloads are deliberately parsed into private-ish, display-safe
4//! values.  Additive fields are ignored, but fields used by the device flow or
5//! credential exchange are required and validated before callers can use them.
6
7use std::fmt;
8
9use chrono::{DateTime, Utc};
10use serde_json::{Map, Value};
11
12const DEVICE_CODE: &str = "device_code";
13const USER_CODE: &str = "user_code";
14const VERIFICATION_URI: &str = "verification_uri";
15const VERIFICATION_URI_COMPLETE: &str = "verification_uri_complete";
16const MAX_OAUTH_FIELD_BYTES: usize = 64 * 1024;
17const MAX_VERIFICATION_URL_BYTES: usize = 8 * 1024;
18const PORTAL_HOST: &str = "portal.nousresearch.com";
19/// A spent allocation comes back as float residue rather than a clean zero
20/// (observed `-1.64e-20` on a drained Ultra plan). Anything within this
21/// distance of zero is treated as zero; a genuine negative balance is still an
22/// error, so the guard against nonsense payloads stays.
23const CREDIT_ZERO_EPSILON: f64 = 1e-6;
24
25/// OAuth device authorization data returned by the portal.
26///
27/// Device/user codes are secret-bearing during the short authorization window;
28/// the custom `Debug` implementation intentionally does not print them.
29#[derive(Clone, PartialEq, Eq)]
30pub struct DeviceCode {
31    pub device_code: String,
32    pub user_code: String,
33    pub verification_uri: String,
34    pub verification_uri_complete: String,
35    pub expires_in: u64,
36    pub interval: u64,
37}
38
39impl fmt::Debug for DeviceCode {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.debug_struct("DeviceCode")
42            .field("device_code", &"<redacted>")
43            .field("user_code", &"<redacted>")
44            .field("verification_uri", &self.verification_uri)
45            .field("verification_uri_complete", &"<redacted>")
46            .field("expires_in", &self.expires_in)
47            .field("interval", &self.interval)
48            .finish()
49    }
50}
51
52/// A validated OAuth token response.
53///
54/// Tokens never appear in `Debug`; callers should also avoid formatting this
55/// value directly in user-facing errors.
56#[derive(Clone, PartialEq, Eq)]
57pub struct TokenResponse {
58    pub access_token: String,
59    pub refresh_token: String,
60    pub token_type: String,
61    pub expires_in: u64,
62}
63
64impl fmt::Debug for TokenResponse {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.debug_struct("TokenResponse")
67            .field("access_token", &"<redacted>")
68            .field("refresh_token", &"<redacted>")
69            .field("token_type", &self.token_type)
70            .field("expires_in", &self.expires_in)
71            .finish()
72    }
73}
74
75/// Display-safe subset of the Nous account response.
76///
77/// The account response may include internal user/organization IDs and future
78/// fields.  Those values are intentionally not represented here.
79#[derive(Debug, Clone, PartialEq)]
80pub struct AccountSnapshot {
81    pub plan: Option<String>,
82    pub tier: Option<i64>,
83    pub monthly_credits: Option<f64>,
84    pub credits_remaining: Option<f64>,
85    pub purchased_credits_remaining: Option<f64>,
86    pub total_usable_credits: Option<f64>,
87    pub rollover_credits: Option<f64>,
88    pub current_period_end: Option<DateTime<Utc>>,
89}
90
91// Parsing rejects non-finite credit values, so equality remains reflexive.
92impl Eq for AccountSnapshot {}
93
94impl AccountSnapshot {
95    /// Percentage consumed from the monthly allocation, if the response gives
96    /// a complete, positive denominator and a non-negative numerator.
97    pub fn usage_percent(&self) -> Option<f64> {
98        let monthly = self.monthly_credits?;
99        let remaining = self.credits_remaining?;
100        if !monthly.is_finite() || !remaining.is_finite() || monthly <= 0.0 || remaining < 0.0 {
101            return None;
102        }
103        Some(((monthly - remaining) / monthly * 100.0).clamp(0.0, 100.0))
104    }
105}
106
107/// Parse the official device-code response, ignoring additive fields.
108pub fn parse_device_code(value: &Value) -> Result<DeviceCode, String> {
109    let object = object(value, "device-code response")?;
110    let device_code = required_nonempty_string(object, DEVICE_CODE)?;
111    let user_code = required_nonempty_string(object, USER_CODE)?;
112    let verification_uri = required_https_url(object, VERIFICATION_URI)?;
113    let verification_uri_complete = required_https_url(object, VERIFICATION_URI_COMPLETE)?;
114    let expires_in = required_positive_u64(object, "expires_in")?;
115    let interval = required_positive_u64(object, "interval")?;
116
117    Ok(DeviceCode {
118        device_code,
119        user_code,
120        verification_uri,
121        verification_uri_complete,
122        expires_in,
123        interval,
124    })
125}
126
127/// Parse a successful device/refresh token response.
128pub fn parse_token(value: &Value) -> Result<TokenResponse, String> {
129    let object = object(value, "token response")?;
130    let access_token = required_nonempty_string(object, "access_token")?;
131    let refresh_token = required_nonempty_string(object, "refresh_token")?;
132    let token_type = match object.get("token_type") {
133        Some(_) => required_nonempty_string(object, "token_type")?,
134        None => "Bearer".to_string(),
135    };
136    if !token_type.eq_ignore_ascii_case("bearer") {
137        return Err("token response has unsupported token_type".into());
138    }
139    let expires_in = required_positive_u64(object, "expires_in")?;
140
141    Ok(TokenResponse {
142        access_token,
143        refresh_token,
144        token_type,
145        expires_in,
146    })
147}
148
149/// Parse an account response into a display-safe snapshot.
150pub fn parse_account(value: &Value) -> Result<AccountSnapshot, String> {
151    let object = object(value, "account response")?;
152    if object.contains_key("error") || object.contains_key("errors") {
153        return Err("account response is an error envelope".into());
154    }
155
156    let mut sources = Vec::with_capacity(3);
157    if let Some(subscription) = object.get("subscription").and_then(Value::as_object) {
158        sources.push(subscription);
159    }
160    if let Some(access) = object.get("paid_service_access").and_then(Value::as_object) {
161        sources.push(access);
162    }
163    sources.push(object);
164
165    let plan = optional_nonempty_string(&sources, &["plan", "plan_name", "planName"])?;
166    let tier =
167        optional_nonnegative_i64(&sources, &["tier", "subscription_tier", "subscriptionTier"])?;
168    let monthly_credits = optional_credit(&sources, &["monthly_credits", "monthlyCredits"])?;
169    let credits_remaining = optional_credit(
170        &sources,
171        &[
172            "credits_remaining",
173            "creditsRemaining",
174            "subscription_credits_remaining",
175            "subscriptionCreditsRemaining",
176        ],
177    )?;
178    let purchased_credits_remaining = optional_credit(
179        &sources,
180        &[
181            "purchased_credits_remaining",
182            "purchasedCreditsRemaining",
183            "top_up_credits_remaining",
184            "topUpCreditsRemaining",
185        ],
186    )?;
187    let total_usable_credits = optional_credit(
188        &sources,
189        ["total_usable_credits", "totalUsableCredits"].as_slice(),
190    )?;
191    let rollover_credits = optional_credit(
192        &sources,
193        &[
194            "rollover_credits",
195            "rolloverCredits",
196            "additional_credits",
197            "additionalCredits",
198        ],
199    )?;
200    let current_period_end = optional_timestamp(
201        &sources,
202        &[
203            "period_end",
204            "current_period_end",
205            "currentPeriodEnd",
206            "renewal_at",
207            "renewalAt",
208        ],
209    )?;
210
211    // A payload containing only an internal ID or an unrelated error/status is
212    // not an account contract. Nested subscription/access objects are valid
213    // account envelopes even when their optional metrics are unavailable.
214    let has_known_field = [
215        &["plan", "plan_name", "planName"][..],
216        &["tier", "subscription_tier", "subscriptionTier"][..],
217        &["monthly_credits", "monthlyCredits"][..],
218        &[
219            "credits_remaining",
220            "creditsRemaining",
221            "subscription_credits_remaining",
222            "subscriptionCreditsRemaining",
223        ][..],
224        &["total_usable_credits", "totalUsableCredits"][..],
225        &[
226            "purchased_credits_remaining",
227            "purchasedCreditsRemaining",
228            "top_up_credits_remaining",
229            "topUpCreditsRemaining",
230        ][..],
231        &[
232            "rollover_credits",
233            "rolloverCredits",
234            "additional_credits",
235            "additionalCredits",
236        ][..],
237        &[
238            "period_end",
239            "current_period_end",
240            "currentPeriodEnd",
241            "renewal_at",
242            "renewalAt",
243        ][..],
244    ]
245    .into_iter()
246    .flatten()
247    .any(|key| sources.iter().any(|source| source.contains_key(*key)))
248        || object.get("subscription").is_some_and(Value::is_object)
249        || object
250            .get("paid_service_access")
251            .is_some_and(Value::is_object);
252    if !has_known_field {
253        return Err("account response has no supported display fields".into());
254    }
255
256    Ok(AccountSnapshot {
257        plan,
258        tier,
259        monthly_credits,
260        credits_remaining,
261        purchased_credits_remaining,
262        total_usable_credits,
263        rollover_credits,
264        current_period_end,
265    })
266}
267
268fn object<'a>(value: &'a Value, name: &str) -> Result<&'a Map<String, Value>, String> {
269    value
270        .as_object()
271        .ok_or_else(|| format!("{name} must be a JSON object"))
272}
273
274fn required_nonempty_string(object: &Map<String, Value>, field: &str) -> Result<String, String> {
275    let value = object
276        .get(field)
277        .ok_or_else(|| format!("missing required field `{field}`"))?;
278    let text = value
279        .as_str()
280        .ok_or_else(|| format!("field `{field}` must be a string"))?;
281    if text.trim().is_empty()
282        || text.len() > MAX_OAUTH_FIELD_BYTES
283        || text.chars().any(char::is_control)
284    {
285        return Err(format!("field `{field}` must be non-empty"));
286    }
287    Ok(text.to_owned())
288}
289
290fn required_https_url(object: &Map<String, Value>, field: &str) -> Result<String, String> {
291    let value = required_nonempty_string(object, field)?;
292    let parsed =
293        reqwest::Url::parse(&value).map_err(|_| format!("field `{field}` must be an HTTPS URL"))?;
294    if value.len() > MAX_VERIFICATION_URL_BYTES
295        || parsed.scheme() != "https"
296        || parsed.host_str() != Some(PORTAL_HOST)
297        || parsed.port_or_known_default() != Some(443)
298        || !parsed.username().is_empty()
299        || parsed.password().is_some()
300    {
301        return Err(format!("field `{field}` must be an HTTPS URL"));
302    }
303    Ok(value)
304}
305
306fn required_positive_u64(object: &Map<String, Value>, field: &str) -> Result<u64, String> {
307    let value = object
308        .get(field)
309        .ok_or_else(|| format!("missing required field `{field}`"))?;
310    let number = match value {
311        Value::Number(number) => number.as_u64(),
312        Value::String(text) => text.trim().parse::<u64>().ok(),
313        _ => None,
314    }
315    .ok_or_else(|| format!("field `{field}` must be a positive integer"))?;
316    if number == 0 {
317        return Err(format!("field `{field}` must be positive"));
318    }
319    Ok(number)
320}
321
322fn first<'a>(objects: &[&'a Map<String, Value>], fields: &[&str]) -> Option<&'a Value> {
323    objects
324        .iter()
325        .find_map(|object| fields.iter().find_map(|field| object.get(*field)))
326}
327
328fn optional_nonempty_string(
329    objects: &[&Map<String, Value>],
330    fields: &[&str],
331) -> Result<Option<String>, String> {
332    let Some(value) = first(objects, fields) else {
333        return Ok(None);
334    };
335    if value.is_null() {
336        return Ok(None);
337    }
338    let text = value
339        .as_str()
340        .ok_or_else(|| "account text field must be a string".to_string())?;
341    if text.trim().is_empty() || text.chars().any(char::is_control) {
342        return Err("account text field must be non-empty".into());
343    }
344    Ok(Some(text.to_owned()))
345}
346
347fn optional_nonnegative_i64(
348    objects: &[&Map<String, Value>],
349    fields: &[&str],
350) -> Result<Option<i64>, String> {
351    let Some(value) = first(objects, fields) else {
352        return Ok(None);
353    };
354    if value.is_null() {
355        return Ok(None);
356    }
357    let number = value
358        .as_i64()
359        .ok_or_else(|| "account tier must be a non-negative integer".to_string())?;
360    if number < 0 {
361        return Err("account tier cannot be negative".into());
362    }
363    Ok(Some(number))
364}
365
366fn optional_credit(
367    objects: &[&Map<String, Value>],
368    fields: &[&str],
369) -> Result<Option<f64>, String> {
370    let Some(value) = first(objects, fields) else {
371        return Ok(None);
372    };
373    if value.is_null() {
374        return Ok(None);
375    }
376    let number = match value {
377        Value::Number(number) => number
378            .as_f64()
379            .ok_or_else(|| "account credit is not a finite number".to_string())?,
380        Value::String(text) => text
381            .trim()
382            .parse::<f64>()
383            .map_err(|_| "account credit is not numeric".to_string())?,
384        _ => return Err("account credit must be a number".into()),
385    };
386    if !number.is_finite() {
387        return Err("account credit must be finite and non-negative".into());
388    }
389    if number < 0.0 {
390        if number > -CREDIT_ZERO_EPSILON {
391            return Ok(Some(0.0));
392        }
393        return Err("account credit must be finite and non-negative".into());
394    }
395    Ok(Some(number))
396}
397
398fn optional_timestamp(
399    objects: &[&Map<String, Value>],
400    fields: &[&str],
401) -> Result<Option<DateTime<Utc>>, String> {
402    let Some(value) = first(objects, fields) else {
403        return Ok(None);
404    };
405    if value.is_null() {
406        return Ok(None);
407    }
408    let text = value
409        .as_str()
410        .ok_or_else(|| "account period end must be an RFC3339 string".to_string())?;
411    DateTime::parse_from_rfc3339(text)
412        .map(|date| date.with_timezone(&Utc))
413        .map(Some)
414        .map_err(|_| "account period end is not a valid RFC3339 timestamp".into())
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn debug_output_redacts_device_and_token_values() {
423        let device = DeviceCode {
424            device_code: "test-device-secret".into(),
425            user_code: "test-user-secret".into(),
426            verification_uri: "https://portal.nousresearch.com/device".into(),
427            verification_uri_complete:
428                "https://portal.nousresearch.com/device?code=test-user-secret".into(),
429            expires_in: 900,
430            interval: 5,
431        };
432        let token = TokenResponse {
433            access_token: "test-access-secret".into(),
434            refresh_token: "test-refresh-secret".into(),
435            token_type: "Bearer".into(),
436            expires_in: 3600,
437        };
438        let output = format!("{device:?} {token:?}");
439        assert!(!output.contains("test-device-secret"));
440        assert!(!output.contains("test-user-secret"));
441        assert!(!output.contains("test-access-secret"));
442        assert!(!output.contains("test-refresh-secret"));
443    }
444
445    #[test]
446    fn account_snapshot_only_retains_display_safe_fields() {
447        let value = serde_json::json!({
448            "plan": "Pro",
449            "user_id": "test-user-id",
450            "organization_id": "test-org-id",
451            "monthly_credits": 10.0,
452        });
453        let snapshot = parse_account(&value).unwrap();
454        let debug = format!("{snapshot:?}");
455        assert!(debug.contains("Pro"));
456        assert!(!debug.contains("test-user-id"));
457        assert!(!debug.contains("test-org-id"));
458    }
459
460    #[test]
461    fn oauth_urls_are_structural_and_secret_fields_are_bounded() {
462        let mut device = serde_json::json!({
463            "device_code": "test-device",
464            "user_code": "TEST",
465            "verification_uri": "https://portal.nousresearch.com/device",
466            "verification_uri_complete": "https://portal.nousresearch.com/device?code=TEST",
467            "expires_in": 900,
468            "interval": 5
469        });
470        assert!(parse_device_code(&device).is_ok());
471
472        device["verification_uri_complete"] =
473            serde_json::json!("https://user@portal.nousresearch.com/device");
474        assert!(parse_device_code(&device).is_err());
475        device["verification_uri_complete"] =
476            serde_json::json!("https://portal.nousresearch.com.evil.test/device");
477        assert!(parse_device_code(&device).is_err());
478
479        let token = serde_json::json!({
480            "access_token": "x".repeat(MAX_OAUTH_FIELD_BYTES + 1),
481            "refresh_token": "test-refresh",
482            "expires_in": 3600
483        });
484        assert!(parse_token(&token).is_err());
485    }
486}