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