From 4ceb88f03c76177e56a9459997999877a364d231 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:34:59 +0700 Subject: [PATCH 1/5] Add transient provider inventory plumbing --- .../src-tauri/src/commands/bridge.rs | 24 +++++ .../src-tauri/src/commands/provider_detail.rs | 3 + .../src-tauri/src/commands/providers.rs | 1 + .../src-tauri/src/commands/tests.rs | 44 +++++++- apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 1 + .../src-tauri/src/usage_metric.rs | 1 + .../src/components/MenuCardDetails.tsx | 51 ++++++++- .../providers/sections/UsageSection.test.tsx | 23 ++++ .../providers/sections/UsageSection.tsx | 33 +++++- apps/desktop-tauri/src/types/bridge.ts | 11 ++ rust/src/cli/usage.rs | 100 +++++++++++++++++- rust/src/core/usage_snapshot.rs | 50 +++++++++ 13 files changed, 338 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 8cd5e91924..1a86eab7b4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -178,6 +178,17 @@ pub struct SubscriptionMetadataSnapshot { pub renews_at: Option, } +/// Display-only provider inventory. Redemption identifiers never cross the +/// bridge. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryItemSnapshot { + pub id: String, + pub title: String, + pub available_count: u32, + pub next_expires_at: Option, +} + /// A frontend-friendly snapshot of one provider's usage data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -200,6 +211,8 @@ pub struct ProviderUsageSnapshot { pub tertiary: Option, #[serde(default)] pub extra_rate_windows: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub inventory: Vec, #[serde(default)] pub cost: Option, #[serde(default)] @@ -384,6 +397,16 @@ impl ProviderUsageSnapshot { window: RateWindowSnapshot::from_rate_window(&extra.window), }) .collect(), + inventory: result + .inventory + .iter() + .map(|item| ProviderInventoryItemSnapshot { + id: item.id.clone(), + title: item.title.clone(), + available_count: item.available_count, + next_expires_at: item.next_expires_at.map(|date| date.to_rfc3339()), + }) + .collect(), cost: result.cost.as_ref().map(|c| CostSnapshotBridge { used: c.used, limit: c.limit, @@ -461,6 +484,7 @@ impl ProviderUsageSnapshot { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 7690a5b2aa..18b1def39b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -26,6 +26,7 @@ pub struct ProviderDetail { pub model_specific: Option, pub tertiary: Option, pub extra_rate_windows: Vec, + pub inventory: Vec, // Cost / pace. pub cost: Option, @@ -91,6 +92,7 @@ pub(crate) fn build_provider_detail(provider_id: &str) -> Result::from_timestamp(1_900_000_000, 0).unwrap(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(12.0)), + "web", + ) + .with_inventory_item(ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(expiry), + }); + let metadata = instantiate_provider(ProviderId::Grok).metadata().clone(); + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Grok, &metadata, &result, None); + + assert_eq!(snapshot.inventory.len(), 1); + assert_eq!(snapshot.inventory[0].available_count, 2); + assert_eq!( + snapshot.inventory[0].next_expires_at.as_deref(), + Some("2030-03-17T17:46:40+00:00") + ); + let serialized = serde_json::to_string(&snapshot).unwrap(); + assert!(serialized.contains("reset-credits")); + assert!(!serialized.contains("coupon-token-secret")); +} + #[test] fn provider_cache_is_fresh_inside_stale_window() { assert!(super::is_provider_cache_fresh( @@ -1057,6 +1085,7 @@ fn provider_cache_upsert_replaces_existing_provider() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1083,6 +1112,7 @@ fn provider_cache_prunes_disabled_providers() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1116,6 +1146,7 @@ fn hiding_codex_spark_rows_preserves_other_extra_usage() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1149,6 +1180,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1184,6 +1216,7 @@ fn codex_transient_transport_failure_helper_uses_typed_policy() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1218,6 +1251,7 @@ fn claude_repeated_auth_failure_surfaces_error() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1259,6 +1293,7 @@ fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1311,6 +1346,7 @@ fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "Web".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1360,6 +1396,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: true, pace_authoritative: true, @@ -1405,6 +1442,7 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1575,6 +1613,7 @@ fn japanese_provider_snapshot_localizes_weekly_label() { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1607,6 +1646,7 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 4f5ce88465..f38f080161 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -195,6 +195,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: Some("Team".to_string()), account_email: Some("dev@example.com".to_string()), @@ -243,6 +244,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 00ba7aff36..0ec2e19b85 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1080,6 +1080,7 @@ mod tests { }), tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: cost.map(|(used, limit)| crate::commands::CostSnapshotBridge { used, limit: Some(limit), diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 73d642534c..697b94d7a2 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -296,6 +296,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 4d0b47d24d..8225a10817 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -3,6 +3,7 @@ import type { CostSummaryDisplayStyle, DailyCostPoint, PaceSnapshot, + ProviderInventoryItem, ProviderChartData, ProviderLocalUsageSummary, ProviderUsageSnapshot, @@ -413,6 +414,7 @@ function MetricRow({ export interface MenuCardPresence { hasMetrics: boolean; + hasInventory: boolean; hasCost: boolean; hasPace: boolean; hasCharts: boolean; @@ -456,6 +458,7 @@ export function describeCard( const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; const hasMetrics = visibleMetrics.length > 0; + const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; const hasCost = !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); @@ -465,9 +468,16 @@ export function describeCard( !!provider.pace; const hasDetails = !provider.error && - (hasMetrics || hasCost || hasPace || hasCharts || !!localUsage || !!wayfinderUsage); + (hasMetrics || + hasInventory || + hasCost || + hasPace || + hasCharts || + !!localUsage || + !!wayfinderUsage); return { hasMetrics, + hasInventory, hasCost, hasPace, hasCharts, @@ -505,6 +515,7 @@ export default function MenuCardDetails({ const { hasMetrics, + hasInventory, hasCost, hasPace, hasCharts, @@ -540,6 +551,18 @@ export default function MenuCardDetails({ )} + {!provider.error && hasInventory && ( +
+ {provider.inventory?.map((item) => ( + + ))} +
+ )} + {wayfinderUsage && } {hasMetrics && hasCost &&
} @@ -712,3 +735,29 @@ export default function MenuCardDetails({
); } + +function InventoryItemRow({ + item, + resetTimeRelative, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; +}) { + const formattedExpiry = useFormattedResetTime( + item.nextExpiresAt, + null, + resetTimeRelative, + "expires", + ); + + return ( +
+ {item.title}: {item.availableCount} available + {formattedExpiry && ( + + {formattedExpiry} + + )} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index 1a4807c882..c32df0cf15 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -102,4 +102,27 @@ describe("UsageSection", () => { expect(label.parentElement).toHaveTextContent("No active 5h session"); expect(label.parentElement?.querySelector(".provider-usage-bar__track")).toBeNull(); }); + + it("renders discrete inventory without turning it into a quota bar", async () => { + const detail = provider(); + detail.session = null; + detail.extraRateWindows = []; + detail.inventory = [ + { + id: "reset-credits", + title: "Limit Reset Credits", + availableCount: 2, + nextExpiresAt: "2099-01-01T00:00:00Z", + }, + ]; + + const { container } = render( + + key} /> + , + ); + + expect(await screen.findByText(/Limit Reset Credits: 2 available/)).toBeInTheDocument(); + expect(container.querySelector(".provider-usage-bar__track")).toBeNull(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index a51c294ed2..64a3fa7b6a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -1,4 +1,5 @@ import type { + ProviderInventoryItem, ProviderDetail, RateWindowSnapshot, } from "../../../../types/bridge"; @@ -60,7 +61,8 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { }); } - if (bars.length === 0) { + const inventory = provider.inventory ?? []; + if (bars.length === 0 && inventory.length === 0) { return null; } @@ -76,10 +78,39 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { t={t} /> ))} + {inventory.map((item) => ( + + ))} ); } +function InventoryRow({ + item, + resetTimeRelative, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; +}) { + const formattedExpiry = useFormattedResetTime( + item.nextExpiresAt, + null, + resetTimeRelative, + "expires", + ); + + return ( +
+ {item.title}: {item.availableCount} available + {formattedExpiry && {formattedExpiry}} +
+ ); +} + function UsageBar({ label, rate, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 865625efeb..963779ae2f 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -592,6 +592,13 @@ export interface SubscriptionMetadataSnapshot { renewsAt: string | null; } +export interface ProviderInventoryItem { + id: string; + title: string; + availableCount: number; + nextExpiresAt: string | null; +} + /** Backend-classified provider availability state (camelCase serde on the bridge). */ export type ProviderStateKind = | "ready" @@ -618,6 +625,8 @@ export interface ProviderUsageSnapshot { title: string; window: RateWindowSnapshot; }>; + /** Display-only discrete provider inventory; never used as quota math. */ + inventory?: ProviderInventoryItem[]; cost: CostSnapshotBridge | null; planName: string | null; accountEmail: string | null; @@ -892,6 +901,8 @@ export interface ProviderDetail { title: string; window: RateWindowSnapshot; }>; + /** Display-only discrete provider inventory; never used as quota math. */ + inventory?: ProviderInventoryItem[]; cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index e0c19618d9..25b988e5c0 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -1,11 +1,13 @@ //! Usage command implementation +use chrono::{DateTime, Utc}; use clap::Args; use serde::Serialize; use crate::core::{ - CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, RateWindow, SourceMode, - TokenAccountStore, TokenAccountSupport, UsagePace, UsageSnapshot, instantiate_provider, + CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, ProviderInventoryItem, RateWindow, + SourceMode, TokenAccountStore, TokenAccountSupport, UsagePace, UsageSnapshot, + instantiate_provider, }; use crate::settings::ApiKeys; use crate::status::{ProviderStatus as StatusInfo, StatusLevel, fetch_provider_status}; @@ -396,6 +398,23 @@ fn render_json_result( }); } + if !result.inventory.is_empty() { + json_result["inventory"] = serde_json::Value::Array( + result + .inventory + .iter() + .map(|item| { + serde_json::json!({ + "id": &item.id, + "title": &item.title, + "availableCount": item.available_count, + "nextExpiresAt": item.next_expires_at.map(|date| date.to_rfc3339()), + }) + }) + .collect(), + ); + } + if let Some(s) = status { json_result["status"] = serde_json::json!({ "level": format!("{:?}", s.level).to_lowercase(), @@ -460,6 +479,7 @@ pub fn render_text_with_status( append_status_line(&mut lines, status); append_account_lines(&mut lines, &result.usage); append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); + append_inventory_lines(&mut lines, &result.inventory); append_cost_line(&mut lines, result.cost.as_ref()); lines.join("\n") @@ -579,6 +599,38 @@ fn append_usage_window_lines( } } +fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventoryItem]) { + if inventory.is_empty() { + return; + } + let now = Utc::now(); + for item in inventory { + lines.push(format!( + " {}: {} available", + item.title, item.available_count + )); + if let Some(expires_at) = item.next_expires_at { + lines.push(format!( + " Next expires in {}", + format_inventory_countdown(expires_at, now) + )); + } + } +} + +fn format_inventory_countdown(expires_at: DateTime, now: DateTime) -> String { + let seconds = expires_at.signed_duration_since(now).num_seconds(); + if seconds <= 0 { + return "now".to_string(); + } + let minutes = (seconds + 59) / 60; + if minutes >= 24 * 60 { + format!("{}d {}h", minutes / (24 * 60), (minutes / 60) % 24) + } else { + format!("{}h {}m", minutes / 60, minutes % 60) + } +} + fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, use_color: bool) { if window.is_informational { let description = window.reset_description.as_deref().unwrap_or("unavailable"); @@ -935,6 +987,50 @@ mod tests { ); } + #[test] + fn inventory_is_rendered_in_full_text_but_not_brief_text() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_inventory_item( + ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(Utc::now() + chrono::Duration::hours(3)), + }, + ); + + let full = render_text_with_status(ProviderId::Grok, &result, None, false); + let brief = render_brief_text(ProviderId::Grok, &result); + + assert!(full.contains("Limit Reset Credits: 2 available")); + assert!(full.contains("Next expires in")); + assert!(!brief.contains("Limit Reset Credits")); + } + + #[test] + fn json_inventory_is_additive_and_contains_no_redemption_token() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_inventory_item( + ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 1, + next_expires_at: None, + }, + ); + + let json = render_json_result(ProviderId::Grok, result, None); + assert_eq!(json["inventory"][0]["availableCount"], 1); + assert!( + serde_json::to_string(&json) + .unwrap() + .contains("reset-credits") + ); + assert!( + !serde_json::to_string(&json) + .unwrap() + .contains("coupon-token-secret") + ); + } + #[test] fn secondary_label_override_is_shared_by_full_and_brief_renderers() { let result = fetch_result( diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 3cfcd2ccf2..b0a1440814 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -85,6 +85,21 @@ pub struct NamedRateWindow { pub usage_known: bool, } +/// One display-only item of provider-issued discrete inventory. +/// +/// This is deliberately separate from [`RateWindow`]: inventory does not +/// represent a percentage quota and must not participate in quota arithmetic, +/// tray metric selection, pace, notifications, or auto-resume decisions. +/// Provider-specific redemption identifiers stay private to the provider +/// parser and never enter this type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderInventoryItem { + pub id: String, + pub title: String, + pub available_count: u32, + pub next_expires_at: Option>, +} + fn named_rate_window_usage_known_default() -> bool { true } @@ -585,6 +600,13 @@ pub struct ProviderFetchResult { #[serde(skip_serializing_if = "Option::is_none")] pub wayfinder_usage: Option, + /// Transient non-quota inventory for provider-specific display. + /// + /// The field is intentionally skipped by serde: it belongs to the current + /// fetch and must not change persisted `ProviderFetchResult` JSON. + #[serde(skip)] + pub inventory: Vec, + /// Label describing the data source (e.g., "oauth", "web", "cli") pub source_label: String, @@ -613,6 +635,7 @@ impl ProviderFetchResult { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: source_label.into(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -652,6 +675,12 @@ impl ProviderFetchResult { self.wayfinder_usage = Some(usage); self } + + /// Attach one display-only inventory item without exposing redemption IDs. + pub fn with_inventory_item(mut self, item: ProviderInventoryItem) -> Self { + self.inventory.push(item); + self + } } #[cfg(test)] @@ -669,6 +698,27 @@ mod tests { ); } + #[test] + fn fetch_result_inventory_is_transient_and_not_serialized() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let expiry = DateTime::::from_timestamp(1_900_000_000, 0).unwrap(); + let result = + ProviderFetchResult::new(usage, "api").with_inventory_item(ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(expiry), + }); + + assert_eq!(result.inventory.len(), 1); + let encoded = serde_json::to_value(&result).unwrap(); + assert!(encoded.get("inventory").is_none()); + assert!(encoded.get("reset-credits").is_none()); + + let decoded: ProviderFetchResult = serde_json::from_value(encoded).unwrap(); + assert!(decoded.inventory.is_empty()); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); From 4468703a92a7166e84ad2a44290af2ffe3d0d01b Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:47:04 +0700 Subject: [PATCH 2/5] Add transient provider detail carrier --- .../src-tauri/src/commands/bridge.rs | 37 ++++ .../src-tauri/src/commands/provider_detail.rs | 3 + .../src-tauri/src/commands/providers.rs | 1 + .../src-tauri/src/commands/tests.rs | 29 ++- apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 1 + .../src-tauri/src/usage_metric.rs | 1 + .../src/components/MenuCardDetails.tsx | 37 ++++ .../providers/sections/UsageSection.tsx | 28 ++- apps/desktop-tauri/src/types/bridge.ts | 18 ++ rust/src/cli/usage.rs | 62 ++++++ rust/src/core/usage_snapshot.rs | 179 ++++++++++++++++++ 12 files changed, 394 insertions(+), 4 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 1a86eab7b4..96f05a366a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -189,6 +189,25 @@ pub struct ProviderInventoryItemSnapshot { pub next_expires_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDisplayProgressSnapshot { + pub used: f64, + pub total: f64, +} + +/// Display-only provider detail row. It never participates in quota math or +/// core persistence and contains values validated by the provider carrier. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDisplayDetailSnapshot { + pub id: String, + pub title: String, + pub value: String, + pub secondary_value: Option, + pub progress: Option, +} + /// A frontend-friendly snapshot of one provider's usage data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -213,6 +232,8 @@ pub struct ProviderUsageSnapshot { pub extra_rate_windows: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub inventory: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub display_details: Vec, #[serde(default)] pub cost: Option, #[serde(default)] @@ -407,6 +428,21 @@ impl ProviderUsageSnapshot { next_expires_at: item.next_expires_at.map(|date| date.to_rfc3339()), }) .collect(), + display_details: result + .display_details() + .map(|detail| ProviderDisplayDetailSnapshot { + id: detail.id().to_string(), + title: detail.title().to_string(), + value: detail.value().to_string(), + secondary_value: detail.secondary_value().map(ToOwned::to_owned), + progress: detail + .progress() + .map(|progress| ProviderDisplayProgressSnapshot { + used: progress.used(), + total: progress.total(), + }), + }) + .collect(), cost: result.cost.as_ref().map(|c| CostSnapshotBridge { used: c.used, limit: c.limit, @@ -485,6 +521,7 @@ impl ProviderUsageSnapshot { tertiary_label: None, extra_rate_windows: Vec::new(), inventory: Vec::new(), + display_details: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 18b1def39b..df5dd7d9c6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -27,6 +27,7 @@ pub struct ProviderDetail { pub tertiary: Option, pub extra_rate_windows: Vec, pub inventory: Vec, + pub display_details: Vec, // Cost / pace. pub cost: Option, @@ -93,6 +94,7 @@ pub(crate) fn build_provider_detail(provider_id: &str) -> Result 0; const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; + const hasDisplayDetails = !provider.error && (provider.displayDetails?.length ?? 0) > 0; const hasCost = !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); @@ -470,6 +473,7 @@ export function describeCard( !provider.error && (hasMetrics || hasInventory || + hasDisplayDetails || hasCost || hasPace || hasCharts || @@ -478,6 +482,7 @@ export function describeCard( return { hasMetrics, hasInventory, + hasDisplayDetails, hasCost, hasPace, hasCharts, @@ -516,6 +521,7 @@ export default function MenuCardDetails({ const { hasMetrics, hasInventory, + hasDisplayDetails, hasCost, hasPace, hasCharts, @@ -563,6 +569,14 @@ export default function MenuCardDetails({ )} + {!provider.error && hasDisplayDetails && ( +
+ {provider.displayDetails?.map((detail, index) => ( + + ))} +
+ )} + {wayfinderUsage && } {hasMetrics && hasCost &&
} @@ -761,3 +775,26 @@ function InventoryItemRow({
); } + +function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { + const progress = detail.progress; + const progressPercent = progress && Number.isFinite(progress.used) && Number.isFinite(progress.total) && progress.total > 0 + ? Math.max(0, Math.min(100, (progress.used / progress.total) * 100)) + : null; + + return ( +
+
+ {detail.title}: {detail.value} + {detail.secondaryValue && ( + {detail.secondaryValue} + )} +
+ {progressPercent != null && ( +
+
+
+ )} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index 64a3fa7b6a..84323eab68 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -1,4 +1,5 @@ import type { + ProviderDisplayDetail, ProviderInventoryItem, ProviderDetail, RateWindowSnapshot, @@ -62,7 +63,8 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { } const inventory = provider.inventory ?? []; - if (bars.length === 0 && inventory.length === 0) { + const displayDetails = provider.displayDetails ?? []; + if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { return null; } @@ -85,6 +87,9 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { resetTimeRelative={resetTimeRelative} /> ))} + {displayDetails.map((detail, index) => ( + + ))} ); } @@ -111,6 +116,27 @@ function InventoryRow({ ); } +function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { + const progress = detail.progress; + const progressPercent = progress && Number.isFinite(progress.used) && Number.isFinite(progress.total) && progress.total > 0 + ? Math.max(0, Math.min(100, (progress.used / progress.total) * 100)) + : null; + + return ( +
+
+ {detail.title}: {detail.value} + {detail.secondaryValue && {detail.secondaryValue}} +
+ {progressPercent != null && ( +
+
+
+ )} +
+ ); +} + function UsageBar({ label, rate, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 963779ae2f..7fd4af94d1 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -599,6 +599,20 @@ export interface ProviderInventoryItem { nextExpiresAt: string | null; } +export interface ProviderDisplayProgress { + used: number; + total: number; +} + +/** Transient provider detail row; it is display-only and never quota math. */ +export interface ProviderDisplayDetail { + id: string; + title: string; + value: string; + secondaryValue: string | null; + progress: ProviderDisplayProgress | null; +} + /** Backend-classified provider availability state (camelCase serde on the bridge). */ export type ProviderStateKind = | "ready" @@ -627,6 +641,8 @@ export interface ProviderUsageSnapshot { }>; /** Display-only discrete provider inventory; never used as quota math. */ inventory?: ProviderInventoryItem[]; + /** Provider-specific display rows; never used as quota math or persistence. */ + displayDetails?: ProviderDisplayDetail[]; cost: CostSnapshotBridge | null; planName: string | null; accountEmail: string | null; @@ -903,6 +919,8 @@ export interface ProviderDetail { }>; /** Display-only discrete provider inventory; never used as quota math. */ inventory?: ProviderInventoryItem[]; + /** Provider-specific display rows; never used as quota math or persistence. */ + displayDetails?: ProviderDisplayDetail[]; cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 25b988e5c0..9697b883c7 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -415,6 +415,28 @@ fn render_json_result( ); } + if result.display_details().next().is_some() { + json_result["details"] = serde_json::Value::Array( + result + .display_details() + .map(|detail| { + serde_json::json!({ + "id": detail.id(), + "title": detail.title(), + "value": detail.value(), + "secondaryValue": detail.secondary_value(), + "progress": detail.progress().map(|progress| { + serde_json::json!({ + "used": progress.used(), + "total": progress.total(), + }) + }), + }) + }) + .collect(), + ); + } + if let Some(s) = status { json_result["status"] = serde_json::json!({ "level": format!("{:?}", s.level).to_lowercase(), @@ -480,6 +502,7 @@ pub fn render_text_with_status( append_account_lines(&mut lines, &result.usage); append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); append_inventory_lines(&mut lines, &result.inventory); + append_display_detail_lines(&mut lines, result.display_details()); append_cost_line(&mut lines, result.cost.as_ref()); lines.join("\n") @@ -618,6 +641,29 @@ fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventor } } +fn append_display_detail_lines<'a>( + lines: &mut Vec, + details: impl IntoIterator, +) { + for detail in details { + let secondary = detail + .secondary_value() + .map(|value| format!(" ({value})")) + .unwrap_or_default(); + let progress = detail + .progress() + .map(|value| format!(" [{:.2}/{:.2}]", value.used(), value.total())) + .unwrap_or_default(); + lines.push(format!( + " {}: {}{}{}", + detail.title(), + detail.value(), + secondary, + progress + )); + } +} + fn format_inventory_countdown(expires_at: DateTime, now: DateTime) -> String { let seconds = expires_at.signed_duration_since(now).num_seconds(); if seconds <= 0 { @@ -1031,6 +1077,22 @@ mod tests { ); } + #[test] + fn display_details_are_rendered_in_full_text_and_json() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_display_detail( + crate::core::ProviderDisplayDetail::new("credits", "Used this cycle", "12") + .with_secondary_value("Monthly refill: 100") + .with_progress(12.0, 100.0), + ); + + let full = render_text_with_status(ProviderId::Grok, &result, None, false); + let json = render_json_result(ProviderId::Grok, result, None); + + assert!(full.contains("Used this cycle: 12 (Monthly refill: 100) [12.00/100.00]")); + assert_eq!(json["details"][0]["title"], "Used this cycle"); + assert_eq!(json["details"][0]["progress"]["total"], 100.0); + } + #[test] fn secondary_label_override_is_shared_by_full_and_brief_renderers() { let result = fetch_result( diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index b0a1440814..86df6c0caa 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -100,6 +100,121 @@ pub struct ProviderInventoryItem { pub next_expires_at: Option>, } +/// One transient provider detail row for display surfaces. +/// +/// These rows are intentionally separate from quota windows and inventory: +/// providers may report credit balances, subscription metadata, or other +/// values that must be shown without becoming quota math or persisted core +/// fetch state. The builder validates compact, display-safe values before a +/// row enters a fetch result; the desktop bridge may then export those rows +/// as part of its current display snapshot. +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderDisplayDetail { + id: String, + title: String, + value: String, + secondary_value: Option, + progress: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ProviderDisplayProgress { + used: f64, + total: f64, +} + +impl ProviderDisplayDetail { + pub fn new(id: impl Into, title: impl Into, value: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + value: value.into(), + secondary_value: None, + progress: None, + } + } + + pub fn with_secondary_value(mut self, value: impl Into) -> Self { + self.secondary_value = Some(value.into()); + self + } + + pub fn with_progress(mut self, used: f64, total: f64) -> Self { + if used.is_finite() && total.is_finite() && used >= 0.0 && total > 0.0 { + self.progress = Some(ProviderDisplayProgress { used, total }); + } + self + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn value(&self) -> &str { + &self.value + } + + pub fn secondary_value(&self) -> Option<&str> { + self.secondary_value.as_deref() + } + + pub fn progress(&self) -> Option { + self.progress + } + + fn is_display_safe(&self) -> bool { + is_display_safe_text(&self.id, 64) + && is_display_safe_text(&self.title, 128) + && is_display_safe_text(&self.value, 512) + && self + .secondary_value + .as_deref() + .is_none_or(|value| is_display_safe_text(value, 512)) + && self.progress.is_none_or(|progress| { + progress.used.is_finite() + && progress.total.is_finite() + && progress.used >= 0.0 + && progress.total > 0.0 + }) + } +} + +impl ProviderDisplayProgress { + pub fn used(&self) -> f64 { + self.used + } + + pub fn total(&self) -> f64 { + self.total + } +} + +fn is_display_safe_text(value: &str, max_len: usize) -> bool { + if value.is_empty() || value.chars().count() > max_len || value.chars().any(char::is_control) { + return false; + } + + let lower = value.to_ascii_lowercase(); + [ + "authorization:", + "bearer ", + "cookie:", + "set-cookie:", + "access_token", + "api_key", + "api-key", + "client_secret", + "refresh_token", + "x-api-key", + ] + .iter() + .all(|marker| !lower.contains(marker)) +} + fn named_rate_window_usage_known_default() -> bool { true } @@ -607,6 +722,12 @@ pub struct ProviderFetchResult { #[serde(skip)] pub inventory: Vec, + /// Transient provider-specific detail rows for display only. They are not + /// serialized by the core result; use [`Self::display_details`] for an + /// explicit surface projection. + #[serde(skip)] + pub display_details: Vec, + /// Label describing the data source (e.g., "oauth", "web", "cli") pub source_label: String, @@ -636,6 +757,7 @@ impl ProviderFetchResult { cost: None, wayfinder_usage: None, inventory: Vec::new(), + display_details: Vec::new(), source_label: source_label.into(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -681,6 +803,20 @@ impl ProviderFetchResult { self.inventory.push(item); self } + + /// Attach one transient provider-specific detail row without persisting it. + pub fn with_display_detail(mut self, detail: ProviderDisplayDetail) -> Self { + if detail.is_display_safe() && !self.display_details.iter().any(|row| row.id == detail.id) { + self.display_details.push(detail); + } + self + } + + pub fn display_details(&self) -> impl Iterator { + self.display_details + .iter() + .filter(|detail| detail.is_display_safe()) + } } #[cfg(test)] @@ -719,6 +855,49 @@ mod tests { assert!(decoded.inventory.is_empty()); } + #[test] + fn fetch_result_display_details_are_transient_and_validate_progress() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let result = ProviderFetchResult::new(usage, "web").with_display_detail( + ProviderDisplayDetail::new("credits", "Used this cycle", "12") + .with_secondary_value("Monthly refill: 100") + .with_progress(12.0, 100.0), + ); + + let details: Vec<_> = result.display_details().collect(); + assert_eq!(details.len(), 1); + assert!(details[0].progress().is_some()); + assert!( + ProviderDisplayDetail::new("invalid", "Invalid", "value") + .with_progress(f64::NAN, 1.0) + .progress + .is_none() + ); + let encoded = serde_json::to_value(&result).unwrap(); + assert!(encoded.get("display_details").is_none()); + } + + #[test] + fn display_details_reject_secret_markers_and_duplicate_ids() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let result = ProviderFetchResult::new(usage, "web") + .with_display_detail(ProviderDisplayDetail::new("credits", "Credits", "12")) + .with_display_detail(ProviderDisplayDetail::new( + "credits", + "Credits duplicate", + "13", + )) + .with_display_detail(ProviderDisplayDetail::new( + "secret", + "Authorization", + "Bearer hidden", + )); + + let details: Vec<_> = result.display_details().collect(); + assert_eq!(details.len(), 1); + assert_eq!(details[0].value(), "12"); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); From f0126db2c60b2ac60f04d2aa169dbcff8a3a575f Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:33:30 +0700 Subject: [PATCH 3/5] Port Nous Portal subscription credits --- .../src-tauri/src/commands/tests.rs | 13 + .../providers/icons/ProviderIcon-nous.svg | 4 + .../src/components/providers/providerIcons.ts | 3 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +- .../providers/sections/usageSourcePolicy.ts | 6 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + rust/src/cli/serve/dashboard/icons.rs | 4 + .../dashboard/icons/ProviderIcon-nous.svg | 4 + rust/src/core/provider.rs | 13 +- rust/src/core/provider_factory.rs | 11 +- rust/src/core/token_accounts.rs | 3 +- rust/src/providers/mod.rs | 2 + rust/src/providers/nous/mod.rs | 941 ++++++++++++++++++ 13 files changed, 999 insertions(+), 8 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-nous.svg create mode 100644 rust/src/cli/serve/dashboard/icons/ProviderIcon-nous.svg create mode 100644 rust/src/providers/nous/mod.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index a81fd4e41f..0b7d0ec812 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -856,6 +856,19 @@ fn fetch_context_openrouter_falls_back_to_stored_api_key_without_token_accounts( assert_eq!(ctx.api_key.as_deref(), Some("sk-or-v1-stored")); } +#[test] +fn nous_provider_is_oauth_only_and_has_no_cookie_or_cli_lane() { + let provider = instantiate_provider(ProviderId::Nous); + assert_eq!(provider.metadata().display_name, "Nous Portal"); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::OAuth] + ); + assert!(provider.supports_oauth()); + assert!(!provider.supports_web()); + assert!(!provider.supports_cli()); +} + #[test] fn provider_region_set_rejects_non_regional_provider() { let mut s = Settings::default(); diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-nous.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-nous.svg new file mode 100644 index 0000000000..2ca0148189 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-nous.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 0b554eba9b..7521f115ee 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -43,6 +43,7 @@ import mimo from "./icons/ProviderIcon-mimo.svg?raw"; import minimax from "./icons/ProviderIcon-minimax.svg?raw"; import mistral from "./icons/ProviderIcon-mistral.svg?raw"; import notion from "./icons/ProviderIcon-notion.svg?raw"; +import nous from "./icons/ProviderIcon-nous.svg?raw"; import xai from "./icons/ProviderIcon-xai.svg?raw"; import ollama from "./icons/ProviderIcon-ollama.svg?raw"; import opencode from "./icons/ProviderIcon-opencode.svg?raw"; @@ -125,6 +126,7 @@ const RAW: Record = { mimo: tint(mimo), minimax: tint(minimax), notion: tint(notion), + nous: tint(nous), xai: tint(xai), mistral: tint(mistral), ollama: tint(ollama), @@ -220,6 +222,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { zed: { id: "zed", brandColor: "#084ccf", fallbackLetter: "Z" }, qwencloud: { id: "qwencloud", brandColor: "#615CED", fallbackLetter: "Q" }, notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, + nous: { id: "nous", brandColor: "#D6A55C", fallbackLetter: "N", svgPath: RAW.nous }, xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, meta: { id: "meta", brandColor: "#0467DF", fallbackLetter: "M", svgPath: RAW.meta }, }; diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 05c4156ce0..28ecdb3b40 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -32,7 +32,7 @@ const HAS_DASHBOARD = new Set([ "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", "meta", + "xai", "zai", "fireworks", "meta", "nous", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts index 90b88c4ff2..cdd87a48e7 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/usageSourcePolicy.ts @@ -40,6 +40,12 @@ const POLICIES: Readonly> = { }, ], }, + nous: { + options: [ + { value: "auto", label: "Auto", description: "Uses the Hermes Agent login or the configured Nous token." }, + { value: "oauth", label: "Hermes OAuth", description: "Uses the read-only Nous Portal token from Hermes Agent." }, + ], + }, }; export function usageSourcePolicy(providerId: string): UsageSourcePolicy | null { diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 3281d8c070..bc270c9ca7 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -68,4 +68,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["qwencloud", "Qwen Cloud"], ["notion", "Notion AI"], ["meta", "Meta"], + ["nous", "Nous Portal"], ]; diff --git a/rust/src/cli/serve/dashboard/icons.rs b/rust/src/cli/serve/dashboard/icons.rs index 36fc7c3326..6a70cf2470 100644 --- a/rust/src/cli/serve/dashboard/icons.rs +++ b/rust/src/cli/serve/dashboard/icons.rs @@ -217,6 +217,10 @@ static ICONS: &[(&str, &[u8])] = &[ "ProviderIcon-notion", include_bytes!("icons/ProviderIcon-notion.svg"), ), + ( + "ProviderIcon-nous", + include_bytes!("icons/ProviderIcon-nous.svg"), + ), ( "ProviderIcon-ollama", include_bytes!("icons/ProviderIcon-ollama.svg"), diff --git a/rust/src/cli/serve/dashboard/icons/ProviderIcon-nous.svg b/rust/src/cli/serve/dashboard/icons/ProviderIcon-nous.svg new file mode 100644 index 0000000000..2ca0148189 --- /dev/null +++ b/rust/src/cli/serve/dashboard/icons/ProviderIcon-nous.svg @@ -0,0 +1,4 @@ + + + + diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 55ab70d391..f63aa2d9af 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -84,6 +84,7 @@ pub enum ProviderId { Fireworks, #[serde(alias = "metaspark")] Meta, + Nous, } impl ProviderId { @@ -161,6 +162,7 @@ impl ProviderId { ProviderId::Xai, ProviderId::Fireworks, ProviderId::Meta, + ProviderId::Nous, ] } @@ -204,6 +206,7 @@ impl ProviderId { ProviderId::DeepInfra => "deepinfra", ProviderId::Fireworks => "fireworks", ProviderId::Meta => "meta", + ProviderId::Nous => "nous", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -282,6 +285,7 @@ impl ProviderId { ProviderId::DeepInfra => "DeepInfra", ProviderId::Fireworks => "Fireworks", ProviderId::Meta => "Meta", + ProviderId::Nous => "Nous Portal", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -373,6 +377,7 @@ impl ProviderId { ProviderId::DeepInfra => None, ProviderId::Fireworks => None, ProviderId::Meta => None, + ProviderId::Nous => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -446,6 +451,7 @@ impl ProviderId { "deepseek" | "deep-seek" | "ds" => Some(ProviderId::DeepSeek), "deepinfra" | "deep-infra" | "di" => Some(ProviderId::DeepInfra), "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), + "nous" | "nous-portal" | "nous portal" | "hermes" => Some(ProviderId::Nous), "meta" | "metaspark" | "meta-spark" | "muse-spark" | "musespark" | "muse spark" | "meta muse spark" => Some(ProviderId::Meta), "aiand" | "ai&" | "ai-and" | "ai and" => Some(ProviderId::AiAnd), @@ -842,6 +848,9 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("di", ProviderId::DeepInfra); map.insert("fireworks-ai", ProviderId::Fireworks); map.insert("fw", ProviderId::Fireworks); + map.insert("nous-portal", ProviderId::Nous); + map.insert("nous portal", ProviderId::Nous); + map.insert("hermes", ProviderId::Nous); map.insert("metaspark", ProviderId::Meta); map.insert("meta-spark", ProviderId::Meta); map.insert("muse-spark", ProviderId::Meta); @@ -976,6 +985,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Xai => "#8E8E93", ProviderId::Fireworks => "#F25B1C", ProviderId::Meta => "#0467DF", + ProviderId::Nous => "#D6A55C", } } @@ -990,7 +1000,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 71); + assert_eq!(all.len(), 72); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Fireworks)); @@ -1042,6 +1052,7 @@ mod tests { assert!(all.contains(&ProviderId::Notion)); assert!(all.contains(&ProviderId::Xai)); assert!(all.contains(&ProviderId::Meta)); + assert!(all.contains(&ProviderId::Nous)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 093b5cf089..d2eb6af1e1 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -16,11 +16,11 @@ use crate::providers::{ GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MetaProvider, MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, NeuralwattProvider, - NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, - OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, - SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, - VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, - ZedProvider, ZenMuxProvider, ZoomMateProvider, + NotionProvider, NousProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, + OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, + QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, + VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, + XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -100,6 +100,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Xai => Box::new(XaiProvider::new()), ProviderId::Fireworks => Box::new(FireworksProvider::new()), ProviderId::Meta => Box::new(MetaProvider::new()), + ProviderId::Nous => Box::new(NousProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index a8ae172b17..156e713fa6 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -352,7 +352,8 @@ impl TokenAccountSupport { | ProviderId::Wayfinder | ProviderId::QwenCloud | ProviderId::Fireworks - | ProviderId::Meta => None, + | ProviderId::Meta + | ProviderId::Nous => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 1e32aaecd5..c68d56b603 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -53,6 +53,7 @@ pub mod mistral; pub mod nanogpt; pub mod neuralwatt; pub mod notion; +pub mod nous; pub mod ollama; pub mod openai; pub mod openaiapi; @@ -127,6 +128,7 @@ pub use mistral::MistralProvider; pub use nanogpt::NanoGPTProvider; pub use neuralwatt::NeuralwattProvider; pub use notion::NotionProvider; +pub use nous::NousProvider; pub use ollama::OllamaProvider; pub use openaiapi::OpenAIApiProvider; pub use opencode::OpenCodeProvider; diff --git a/rust/src/providers/nous/mod.rs b/rust/src/providers/nous/mod.rs new file mode 100644 index 0000000000..25c27e8558 --- /dev/null +++ b/rust/src/providers/nous/mod.rs @@ -0,0 +1,941 @@ +//! Nous Portal subscription provider. +//! +//! Nous Portal issues short-lived access tokens through the Hermes Agent +//! device-code login. The Windows port reads those credentials without +//! refreshing or writing them, then projects the account endpoint into the +//! monthly subscription-credit display used by the rest of the app. + +use async_trait::async_trait; +use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc}; +use reqwest::{Client, StatusCode, Url}; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tokio::time::{Duration, timeout}; + +use crate::core::{ + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, +}; + +const DEFAULT_PORTAL_URL: &str = "https://portal.nousresearch.com"; +const PORTAL_ACCOUNT_PATH: &str = "api/oauth/account"; +const ACCESS_TOKEN_ENV: &str = "NOUS_PORTAL_ACCESS_TOKEN"; +const PORTAL_URL_ENVS: &[&str] = &["NOUS_PORTAL_BASE_URL", "HERMES_PORTAL_BASE_URL"]; +const HERMES_HOME_ENV: &str = "HERMES_HOME"; +const MAX_RESPONSE_BYTES: usize = 512 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const EXPIRY_SKEW: i64 = 60; +const TRUSTED_PORTAL_HOST: &str = "nousresearch.com"; + +#[derive(Debug, Clone)] +struct Credential { + token: String, + portal_url: Url, + expires_at: Option>, +} + +impl Credential { + fn is_expired(&self, now: DateTime) -> bool { + self.expires_at + .is_some_and(|expires_at| expires_at <= now + ChronoDuration::seconds(EXPIRY_SKEW)) + } +} + +#[derive(Debug, Clone)] +struct StoredCredential { + token: String, + portal_base_url: Option, + expires_at: Option>, +} + +pub struct NousProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl NousProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Nous, + display_name: "Nous Portal", + session_label: "Monthly credits", + weekly_label: "Weekly", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://portal.nousresearch.com/usage"), + status_page_url: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + async fn fetch_api( + &self, + explicit_token: Option<&str>, + ) -> Result { + let credential = resolve_credential(explicit_token)?; + let endpoint = credential + .portal_url + .join(PORTAL_ACCOUNT_PATH) + .map_err(|_| ProviderError::Parse("Nous Portal URL is invalid.".to_string()))?; + let response = timeout( + REQUEST_TIMEOUT, + self.client + .get(endpoint) + .bearer_auth(&credential.token) + .header("Accept", "application/json") + .header("User-Agent", "CodexBar") + .send(), + ) + .await + .map_err(|_| ProviderError::Timeout)??; + + let status = response.status(); + if status != StatusCode::OK { + return Err(status_error(status)); + } + if response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + { + return Err(ProviderError::Parse( + "Nous Portal returned an oversized response.".to_string(), + )); + } + + let body = response.bytes().await.map_err(ProviderError::Network)?; + if body.len() > MAX_RESPONSE_BYTES { + return Err(ProviderError::Parse( + "Nous Portal returned an oversized response.".to_string(), + )); + } + parse_response(&body) + } +} + +impl Default for NousProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for NousProvider { + fn id(&self) -> ProviderId { + ProviderId::Nous + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx.api_key.as_deref()).await, + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } + + fn supports_oauth(&self) -> bool { + true + } +} + +fn resolve_credential(explicit_token: Option<&str>) -> Result { + let environment: HashMap = std::env::vars().collect(); + let home = dirs::home_dir().ok_or_else(missing_credentials)?; + resolve_credential_from(explicit_token, &environment, &home, Utc::now()) +} + +fn resolve_credential_from( + explicit_token: Option<&str>, + environment: &HashMap, + home_directory: &Path, + now: DateTime, +) -> Result { + if let Some(token) = cleaned(explicit_token) { + return usable_credential(token, resolve_portal_url(environment, None), now); + } + if let Some(token) = cleaned(environment.get(ACCESS_TOKEN_ENV).map(String::as_str)) { + return usable_credential(token, resolve_portal_url(environment, None), now); + } + + let candidates = auth_file_candidates(environment, home_directory); + let mut saw_file = false; + let mut expired: Option = None; + for path in &candidates { + let Ok(contents) = std::fs::read(path) else { + continue; + }; + saw_file = true; + let Some(stored) = parse_auth_file(&contents) else { + continue; + }; + let credential = Credential { + expires_at: stored.expires_at.or_else(|| jwt_expiry(&stored.token)), + portal_url: resolve_portal_url(environment, stored.portal_base_url.as_deref()), + token: stored.token, + }; + if credential.is_expired(now) { + expired.get_or_insert(credential); + } else { + return Ok(credential); + } + } + + if expired.is_some() { + return Err(ProviderError::OAuthExpired( + "Nous Portal Hermes login expired. Run hermes to refresh it.".to_string(), + )); + } + if saw_file { + return Err(ProviderError::NotInstalled( + "Nous Portal auth files contain no usable login. Run hermes to sign in again." + .to_string(), + )); + } + Err(missing_credentials()) +} + +fn usable_credential( + token: String, + portal_url: Url, + now: DateTime, +) -> Result { + let credential = Credential { + expires_at: jwt_expiry(&token), + token, + portal_url, + }; + if credential.is_expired(now) { + return Err(ProviderError::OAuthExpired( + "Nous Portal access token expired. Run hermes to refresh it.".to_string(), + )); + } + Ok(credential) +} + +fn missing_credentials() -> ProviderError { + ProviderError::NotInstalled( + "Nous Portal login not found. Run hermes to sign in, then refresh CodexBar.".to_string(), + ) +} + +fn auth_file_candidates( + environment: &HashMap, + home_directory: &Path, +) -> Vec { + let root = environment + .get(HERMES_HOME_ENV) + .and_then(|raw| cleaned(Some(raw.as_str()))) + .map(|raw| expand_home(&raw, home_directory)) + .unwrap_or_else(|| { + let home = environment + .get("HOME") + .and_then(|raw| cleaned(Some(raw.as_str()))) + .map(|raw| expand_home(&raw, home_directory)) + .unwrap_or_else(|| home_directory.to_path_buf()); + home.join(".hermes") + }); + vec![ + root.join("auth.json"), + root.join("shared").join("nous_auth.json"), + ] +} + +fn expand_home(raw: &str, home_directory: &Path) -> PathBuf { + if raw == "~" { + return home_directory.to_path_buf(); + } + if let Some(rest) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) { + return home_directory.join(rest); + } + PathBuf::from(raw) +} + +fn parse_auth_file(contents: &[u8]) -> Option { + let root: Value = serde_json::from_slice(contents).ok()?; + let root_object = root.as_object()?; + + if let Some(providers) = root_object.get("providers").and_then(Value::as_object) + && let Some(nous) = providers.get("nous") + && let Some(stored) = stored_credential(nous) + { + return Some(stored); + } + + if let Some(entries) = root_object + .get("credential_pool") + .and_then(Value::as_object) + .and_then(|pool| pool.get("nous")) + .and_then(Value::as_array) + { + return select_pool_credential(entries); + } + + stored_credential(&root) +} + +fn select_pool_credential(entries: &[Value]) -> Option { + let mut selected: Option<(StoredCredential, i64, i64, i64)> = None; + for entry in entries { + let Some(stored) = stored_credential(entry) else { + continue; + }; + let agent_expiry = entry + .get("agent_key_expires_at") + .and_then(Value::as_str) + .and_then(parse_iso) + .map(|value| value.timestamp()) + .unwrap_or(0); + let access_expiry = stored + .expires_at + .or_else(|| jwt_expiry(&stored.token)) + .map(|value| value.timestamp()) + .unwrap_or(0); + let priority = entry.get("priority").and_then(Value::as_i64).unwrap_or(0); + let should_replace = + selected + .as_ref() + .is_none_or(|(_, old_agent, old_access, old_priority)| { + agent_expiry > *old_agent + || (agent_expiry == *old_agent + && (access_expiry > *old_access + || (access_expiry == *old_access && priority < *old_priority))) + }); + if should_replace { + selected = Some((stored, agent_expiry, access_expiry, priority)); + } + } + selected.map(|(stored, _, _, _)| stored) +} + +fn stored_credential(value: &Value) -> Option { + let object = value.as_object()?; + let token = cleaned(object.get("access_token").and_then(Value::as_str))?; + Some(StoredCredential { + token, + portal_base_url: cleaned(object.get("portal_base_url").and_then(Value::as_str)), + expires_at: object + .get("expires_at") + .and_then(Value::as_str) + .and_then(parse_iso), + }) +} + +fn resolve_portal_url(environment: &HashMap, stored: Option<&str>) -> Url { + for key in PORTAL_URL_ENVS { + if let Some(raw) = environment.get(*key).and_then(|value| cleaned(Some(value))) + && let Some(url) = normalized_https_url(&raw) + { + return url; + } + } + if let Some(raw) = stored.and_then(|value| cleaned(Some(value))) + && let Some(url) = normalized_https_url(&raw) + && is_trusted_portal_host(url.host_str()) + { + return url; + } + Url::parse(DEFAULT_PORTAL_URL).expect("default Nous Portal URL is valid") +} + +fn normalized_https_url(raw: &str) -> Option { + let value = raw.trim_end_matches('/').trim(); + let url = Url::parse(value).ok()?; + if url.scheme() != "https" + || url.host_str().is_none_or(str::is_empty) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || (!url.path().is_empty() && url.path() != "/") + { + return None; + } + Some(url) +} + +fn is_trusted_portal_host(host: Option<&str>) -> bool { + let Some(host) = host.map(str::to_ascii_lowercase) else { + return false; + }; + host == TRUSTED_PORTAL_HOST || host.ends_with(&format!(".{TRUSTED_PORTAL_HOST}")) +} + +fn cleaned(value: Option<&str>) -> Option { + let mut value = value?.trim().to_string(); + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + value = value[1..value.len() - 1].trim().to_string(); + } + (!value.is_empty()).then_some(value) +} + +fn parse_iso(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|value| value.with_timezone(&Utc)) +} + +fn jwt_expiry(token: &str) -> Option> { + use base64::Engine; + + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) + .ok()?; + let claims: Value = serde_json::from_slice(&decoded).ok()?; + let seconds = claims.get("exp")?.as_f64()?; + if !seconds.is_finite() || seconds <= 0.0 { + return None; + } + #[allow( + clippy::cast_possible_truncation, + reason = "JWT expiration is converted to whole epoch seconds" + )] + let seconds = seconds.trunc() as i64; + DateTime::from_timestamp(seconds, 0) +} + +fn status_error(status: StatusCode) -> ProviderError { + match status { + StatusCode::UNAUTHORIZED => ProviderError::OAuthExpired( + "Nous Portal rejected the access token. Run hermes to refresh the Hermes login." + .to_string(), + ), + StatusCode::FORBIDDEN => { + ProviderError::Other("Nous Portal denied account access.".to_string()) + } + StatusCode::TOO_MANY_REQUESTS => { + ProviderError::Other("Nous Portal account requests are rate limited.".to_string()) + } + status if status.is_server_error() => ProviderError::Other(format!( + "Nous Portal API is unavailable (HTTP {}).", + status.as_u16() + )), + status => ProviderError::Other(format!( + "Nous Portal account API returned HTTP {}.", + status.as_u16() + )), + } +} + +fn parse_response(body: &[u8]) -> Result { + let decoded: Value = serde_json::from_slice(body).map_err(|_| { + ProviderError::Parse("Invalid Nous Portal account response: expected JSON.".to_string()) + })?; + let root = decoded.as_object().ok_or_else(|| { + ProviderError::Parse( + "Invalid Nous Portal account response: expected an object.".to_string(), + ) + })?; + if root.get("error").is_some_and(is_truthy) { + return Err(ProviderError::Other( + "Nous Portal account endpoint reported an error.".to_string(), + )); + } + + let subscription = optional_object(root.get("subscription"), "subscription")?; + let access = optional_object(root.get("paid_service_access"), "paid_service_access")?; + let user = optional_object(root.get("user"), "user")?; + let organization = optional_object(root.get("organisation"), "organisation")?; + + let monthly = number( + subscription.and_then(|value| value.get("monthly_credits")), + "monthly_credits", + )?; + if monthly.is_some_and(|value| value < 0.0) { + return Err(parse_failure("monthly_credits")); + } + let remaining = number( + subscription.and_then(|value| value.get("credits_remaining")), + "credits_remaining", + )? + .or(number( + access.and_then(|value| value.get("subscription_credits_remaining")), + "subscription_credits_remaining", + )?); + let rollover = number( + subscription.and_then(|value| value.get("rollover_credits")), + "rollover_credits", + )?; + let purchased = number( + root.get("purchased_credits_remaining"), + "purchased_credits_remaining", + )? + .or(number( + access.and_then(|value| value.get("purchased_credits_remaining")), + "paid_service_access.purchased_credits_remaining", + )?); + let total = number( + access.and_then(|value| value.get("total_usable_credits")), + "total_usable_credits", + )?; + if [monthly, remaining, rollover, purchased, total] + .iter() + .all(Option::is_none) + { + return Err(parse_failure("no credit amounts")); + } + + let renewal = optional_date( + subscription.and_then(|value| value.get("current_period_end")), + "current_period_end", + )?; + let primary = if let (Some(monthly), Some(remaining)) = + (monthly.filter(|value| *value > 0.0), remaining) + { + let used = (monthly - remaining.max(0.0)).clamp(0.0, monthly); + RateWindow::with_details( + used / monthly * 100.0, + RateWindow::monthly_window_minutes(renewal), + renewal, + None, + ) + } else { + RateWindow::informational( + monthly + .map(|value| format!("{} monthly grant", format_usd(value))) + .or_else(|| total.map(|value| format!("{} total usable", format_usd(value)))) + .unwrap_or_else(|| "Nous Portal credits".to_string()), + ) + }; + + let plan = text( + subscription.and_then(|value| value.get("plan")), + "subscription.plan", + )?; + let active_subscription = access + .and_then(|value| value.get("has_active_subscription")) + .and_then(Value::as_bool) + .unwrap_or(false); + let login_method = plan.or_else(|| active_subscription.then(|| "Subscription".to_string())); + let email = text(user.and_then(|value| value.get("email")), "user.email")?; + let organization_name = text( + organization.and_then(|value| value.get("name")), + "organisation.name", + )?; + + let mut usage = UsageSnapshot::new(primary); + if let Some(plan) = login_method { + usage = usage.with_login_method(plan); + } + if let Some(email) = email { + usage = usage.with_email(email); + } + if let Some(organization) = organization_name { + usage = usage.with_organization(organization); + } + if renewal.is_some() { + usage = usage.with_subscription(Some(SubscriptionMetadata::new(None, None, renewal))); + } + + let mut result = ProviderFetchResult::new(usage, "api"); + if let Some(remaining) = remaining { + let remaining = remaining.max(0.0); + let mut detail = ProviderDisplayDetail::new( + "subscription-credits", + "Subscription credits", + monthly + .filter(|value| *value > 0.0) + .map(|monthly| format!("{} of {} left", format_usd(remaining), format_usd(monthly))) + .unwrap_or_else(|| format!("{} left", format_usd(remaining))), + ); + if let Some(monthly) = monthly.filter(|value| *value > 0.0) { + let used = (monthly - remaining).clamp(0.0, monthly); + detail = detail.with_progress(used, monthly); + } + result = result.with_display_detail(detail); + } else if let Some(monthly) = monthly { + result = result.with_display_detail(ProviderDisplayDetail::new( + "monthly-grant", + "Monthly grant", + format_usd(monthly), + )); + } + if let Some(rollover) = rollover.filter(|value| *value > 0.0) { + result = result.with_display_detail(ProviderDisplayDetail::new( + "rollover-credits", + "Rollover credits", + format_usd(rollover), + )); + } + if let Some(renewal) = renewal { + result = result.with_display_detail(ProviderDisplayDetail::new( + "renewal", + "Renews", + format_month_day(renewal), + )); + } + if let Some(purchased) = purchased { + result = result.with_display_detail(ProviderDisplayDetail::new( + "top-up-credits", + "Top-up credits", + format_usd(purchased), + )); + } + if let Some(total) = total { + result = result.with_display_detail(ProviderDisplayDetail::new( + "total-usable", + "Total usable", + format_usd(total), + )); + } + Ok(result) +} + +fn optional_object<'a>( + value: Option<&'a Value>, + field: &str, +) -> Result>, ProviderError> { + match value { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_object() + .map(Some) + .ok_or_else(|| parse_failure(field)), + } +} + +fn number(value: Option<&Value>, field: &str) -> Result, ProviderError> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let parsed = match value { + Value::Number(value) => value.as_f64(), + Value::String(value) => value.trim().parse::().ok(), + _ => None, + }; + parsed + .filter(|value| value.is_finite()) + .map(Some) + .ok_or_else(|| parse_failure(field)) +} + +fn optional_date( + value: Option<&Value>, + field: &str, +) -> Result>, ProviderError> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let raw = value.as_str().ok_or_else(|| parse_failure(field))?; + parse_iso(raw).map(Some).ok_or_else(|| parse_failure(field)) +} + +fn text(value: Option<&Value>, field: &str) -> Result, ProviderError> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let Some(value) = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + if value.chars().count() > 256 || value.chars().any(char::is_control) { + return Err(parse_failure(field)); + } + Ok(Some(value.to_string())) +} + +fn is_truthy(value: &Value) -> bool { + match value { + Value::Null | Value::Bool(false) => false, + Value::Number(value) => value.as_f64().is_none_or(|number| number != 0.0), + Value::String(value) => !value.is_empty(), + Value::Array(_) | Value::Object(_) | Value::Bool(true) => true, + } +} + +fn format_usd(value: f64) -> String { + let value = value.max(0.0); + let prefix = "$"; + if value.abs() < 100.0 { + format!("{prefix}{value:.2}") + } else { + format!("{prefix}{value:.0}") + } +} + +fn format_month_day(value: DateTime) -> String { + format!("{} {}", value.format("%b"), value.day()) +} + +fn parse_failure(field: impl Into) -> ProviderError { + ProviderError::Parse(format!( + "Invalid Nous Portal account response: {}", + field.into() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn environment(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + } + + fn success_payload() -> Value { + serde_json::json!({ + "subscription": { + "monthly_credits": 70, + "credits_remaining": 61.5, + "rollover_credits": 2, + "current_period_end": "2026-10-18T12:00:00Z", + "plan": "Pro" + }, + "paid_service_access": { + "subscription_credits_remaining": 61.5, + "purchased_credits_remaining": 4.25, + "total_usable_credits": 67.75, + "has_active_subscription": true + }, + "purchased_credits_remaining": 4.25, + "user": {"email": "user@example.com"}, + "organisation": {"name": "Nous Research"}, + "secret": "must never appear in display data" + }) + } + + #[test] + fn metadata_and_sources_match_oauth_port() { + let provider = NousProvider::new(); + assert_eq!(provider.id(), ProviderId::Nous); + assert_eq!(provider.metadata().display_name, "Nous Portal"); + assert_eq!(provider.metadata().session_label, "Monthly credits"); + assert!(!provider.metadata().default_enabled); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::OAuth] + ); + assert!(provider.supports_oauth()); + } + + #[test] + fn success_payload_maps_monthly_credits_identity_and_details() { + let result = parse_response(&serde_json::to_vec(&success_payload()).unwrap()).unwrap(); + assert_eq!(result.source_label, "api"); + assert!((result.usage.primary.used_percent - 12.142857).abs() < 0.001); + assert_eq!( + result + .usage + .primary + .resets_at + .map(|value| value.to_rfc3339()), + Some("2026-10-18T12:00:00+00:00".to_string()) + ); + assert_eq!( + result.usage.account_email.as_deref(), + Some("user@example.com") + ); + assert_eq!( + result.usage.account_organization.as_deref(), + Some("Nous Research") + ); + assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); + assert_eq!( + result + .usage + .subscription + .as_ref() + .and_then(|value| value.renews_at), + result.usage.primary.resets_at + ); + let details: Vec<_> = result.display_details().collect(); + assert!(details.iter().any(|detail| { + detail.id() == "subscription-credits" + && detail.value().contains("$61.50") + && detail.value().contains("$70.00") + })); + assert!(details.iter().any(|detail| detail.id() == "top-up-credits")); + assert!(details.iter().any(|detail| detail.id() == "total-usable")); + assert!( + details + .iter() + .all(|detail| !detail.value().contains("must never appear")) + ); + } + + #[test] + fn fallback_credit_locations_and_informational_primary_are_supported() { + let payload = serde_json::json!({ + "subscription": {"monthly_credits": 0}, + "paid_service_access": { + "subscription_credits_remaining": 0, + "purchased_credits_remaining": 3, + "total_usable_credits": 3 + } + }); + let result = parse_response(&serde_json::to_vec(&payload).unwrap()).unwrap(); + assert!(result.usage.primary.is_informational); + assert!( + result + .display_details() + .any(|detail| detail.id() == "top-up-credits") + ); + } + + #[test] + fn malformed_credit_fields_fail_closed_without_echoing_payload() { + for payload in [ + serde_json::json!({"subscription": {"monthly_credits": "nope"}}), + serde_json::json!({"subscription": {"monthly_credits": -1}}), + serde_json::json!({"subscription": {"current_period_end": 42}, "purchased_credits_remaining": 1}), + serde_json::json!({"subscription": {}, "paid_service_access": {}}), + ] { + let error = parse_response(&serde_json::to_vec(&payload).unwrap()).unwrap_err(); + assert!(matches!(error, ProviderError::Parse(_))); + assert!(!error.to_string().contains("nope")); + } + } + + #[test] + fn endpoint_errors_are_classified_without_response_body() { + assert!(matches!( + status_error(StatusCode::UNAUTHORIZED), + ProviderError::OAuthExpired(_) + )); + assert!( + status_error(StatusCode::FORBIDDEN) + .to_string() + .contains("denied") + ); + assert!( + status_error(StatusCode::TOO_MANY_REQUESTS) + .to_string() + .contains("rate limited") + ); + assert!( + status_error(StatusCode::INTERNAL_SERVER_ERROR) + .to_string() + .contains("unavailable") + ); + assert!( + status_error(StatusCode::BAD_REQUEST) + .to_string() + .contains("HTTP 400") + ); + } + + #[test] + fn auth_file_supports_hermes_provider_state_and_custom_home() { + let dir = tempdir().unwrap(); + let auth = dir.path().join("auth.json"); + fs::write( + &auth, + r#"{"providers":{"nous":{"access_token":"token-value","portal_base_url":"https://api.nousresearch.com","expires_at":"2099-01-01T00:00:00Z"}}}"#, + ) + .unwrap(); + let env = environment(&[(HERMES_HOME_ENV, dir.path().to_str().unwrap())]); + let credential = + resolve_credential_from(None, &env, Path::new("C:\\unused"), Utc::now()).unwrap(); + assert_eq!(credential.token, "token-value"); + assert_eq!( + credential.portal_url.host_str(), + Some("api.nousresearch.com") + ); + } + + #[test] + fn credential_pool_uses_agent_expiry_then_access_expiry_then_priority() { + let payload = serde_json::json!({ + "credential_pool": { + "nous": [ + {"access_token": "first", "agent_key_expires_at": "2026-10-01T00:00:00Z", "expires_at": "2026-12-01T00:00:00Z", "priority": 0}, + {"access_token": "second", "agent_key_expires_at": "2026-11-01T00:00:00Z", "expires_at": "2026-10-01T00:00:00Z", "priority": 10}, + {"access_token": "third", "agent_key_expires_at": "2026-11-01T00:00:00Z", "expires_at": "2026-10-01T00:00:00Z", "priority": 1} + ] + } + }); + let stored = parse_auth_file(&serde_json::to_vec(&payload).unwrap()).unwrap(); + assert_eq!(stored.token, "third"); + } + + #[test] + fn explicit_hermes_home_is_exclusive_and_expired_tokens_fail_closed() { + let fallback = tempdir().unwrap(); + let custom = tempdir().unwrap(); + fs::create_dir_all(fallback.path().join(".hermes")).unwrap(); + fs::write( + fallback.path().join(".hermes").join("auth.json"), + r#"{"access_token":"fallback"}"#, + ) + .unwrap(); + fs::write( + custom.path().join("auth.json"), + r#"{"access_token":"expired","expires_at":"2020-01-01T00:00:00Z"}"#, + ) + .unwrap(); + let env = environment(&[ + (HERMES_HOME_ENV, custom.path().to_str().unwrap()), + ("HOME", fallback.path().to_str().unwrap()), + ]); + assert!(matches!( + resolve_credential_from(None, &env, fallback.path(), Utc::now()), + Err(ProviderError::OAuthExpired(_)) + )); + } + + #[test] + fn portal_origin_requires_https_and_trusts_only_nousresearch_stored_hosts() { + let empty = HashMap::new(); + let trusted = resolve_portal_url(&empty, Some("https://api.nousresearch.com/")); + assert_eq!(trusted.host_str(), Some("api.nousresearch.com")); + let untrusted = resolve_portal_url(&empty, Some("https://evil.example")); + assert_eq!(untrusted.host_str(), Some("portal.nousresearch.com")); + let invalid_env = environment(&[(PORTAL_URL_ENVS[0], "http://localhost:1234")]); + let defaulted = resolve_portal_url(&invalid_env, None); + assert_eq!(defaulted.host_str(), Some("portal.nousresearch.com")); + let env = environment(&[(PORTAL_URL_ENVS[0], "https://localhost:1234")]); + let overridden = resolve_portal_url(&env, None); + assert_eq!(overridden.host_str(), Some("localhost")); + } + + #[test] + fn jwt_expiry_is_read_only_and_environment_override_wins() { + use base64::Engine; + + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#); + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":1893456000}"#); + let token = format!("{header}.{payload}.signature"); + let env = environment(&[(ACCESS_TOKEN_ENV, &token)]); + let credential = + resolve_credential_from(None, &env, Path::new("C:\\unused"), Utc::now()).unwrap(); + assert_eq!(credential.token, token); + } +} From 727a77105c46f1f28ed415c7045112438065ba7e Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:37:09 +0700 Subject: [PATCH 4/5] Bound Nous response buffering --- rust/src/providers/nous/mod.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/rust/src/providers/nous/mod.rs b/rust/src/providers/nous/mod.rs index 25c27e8558..5bce65787a 100644 --- a/rust/src/providers/nous/mod.rs +++ b/rust/src/providers/nous/mod.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc}; +use futures::StreamExt; use reqwest::{Client, StatusCode, Url}; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -110,12 +111,7 @@ impl NousProvider { )); } - let body = response.bytes().await.map_err(ProviderError::Network)?; - if body.len() > MAX_RESPONSE_BYTES { - return Err(ProviderError::Parse( - "Nous Portal returned an oversized response.".to_string(), - )); - } + let body = read_bounded_body(response).await?; parse_response(&body) } } @@ -437,6 +433,25 @@ fn status_error(status: StatusCode) -> ProviderError { } } +async fn read_bounded_body(response: reqwest::Response) -> Result, ProviderError> { + let mut stream = response.bytes_stream(); + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + append_bounded_body(&mut body, &chunk?)?; + } + Ok(body) +} + +fn append_bounded_body(body: &mut Vec, chunk: &[u8]) -> Result<(), ProviderError> { + if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(body.len()) { + return Err(ProviderError::Parse( + "Nous Portal returned an oversized response.".to_string(), + )); + } + body.extend_from_slice(chunk); + Ok(()) +} + fn parse_response(body: &[u8]) -> Result { let decoded: Value = serde_json::from_slice(body).map_err(|_| { ProviderError::Parse("Invalid Nous Portal account response: expected JSON.".to_string()) @@ -851,6 +866,12 @@ mod tests { ); } + #[test] + fn streaming_response_cap_rejects_oversized_chunk_without_content_length() { + let mut body = vec![0_u8; MAX_RESPONSE_BYTES]; + assert!(append_bounded_body(&mut body, &[0]).is_err()); + } + #[test] fn auth_file_supports_hermes_provider_state_and_custom_home() { let dir = tempdir().unwrap(); From 8aff3933c2a25485189e54ad89de12cf18130dde Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:23:23 +0700 Subject: [PATCH 5/5] Address thermo-nuclear review: decompose credentials and pin policies --- rust/src/cli/usage.rs | 4 +- rust/src/cli/usage/render.rs | 46 +++ rust/src/cli/usage_tests.rs | 16 + rust/src/core/usage_snapshot.rs | 15 - rust/src/providers/nous/credentials.rs | 308 +++++++++++++++++ rust/src/providers/nous/mod.rs | 437 ++++++------------------- 6 files changed, 475 insertions(+), 351 deletions(-) create mode 100644 rust/src/providers/nous/credentials.rs diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 558c4fef6d..b6e8136696 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -1,6 +1,5 @@ //! Usage command implementation -use chrono::{DateTime, Utc}; use clap::Args; use serde::Serialize; @@ -260,3 +259,6 @@ fn build_usage_fetch_context(args: &UsageArgs, source_mode: SourceMode) -> Fetch } } +#[cfg(test)] +#[path = "usage_tests.rs"] +mod tests; diff --git a/rust/src/cli/usage/render.rs b/rust/src/cli/usage/render.rs index 8dedc906ed..df1d423d75 100644 --- a/rust/src/cli/usage/render.rs +++ b/rust/src/cli/usage/render.rs @@ -77,6 +77,28 @@ pub fn render_json_result( }); } + if result.display_details().next().is_some() { + json_result["details"] = serde_json::Value::Array( + result + .display_details() + .map(|detail| { + serde_json::json!({ + "id": detail.id(), + "title": detail.title(), + "value": detail.value(), + "secondaryValue": detail.secondary_value(), + "progress": detail.progress().map(|progress| { + serde_json::json!({ + "used": progress.used(), + "total": progress.total(), + }) + }), + }) + }) + .collect(), + ); + } + json_result } @@ -135,6 +157,7 @@ pub fn render_text_with_status( append_account_lines(&mut lines, &result.usage); append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); append_inventory_lines(&mut lines, &result.inventory); + append_display_detail_lines(&mut lines, result.display_details()); append_cost_line(&mut lines, result.cost.as_ref()); lines.join("\n") @@ -273,6 +296,29 @@ fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventor } } +fn append_display_detail_lines<'a>( + lines: &mut Vec, + details: impl IntoIterator, +) { + for detail in details { + let secondary = detail + .secondary_value() + .map(|value| format!(" ({value})")) + .unwrap_or_default(); + let progress = detail + .progress() + .map(|value| format!(" [{:.2}/{:.2}]", value.used(), value.total())) + .unwrap_or_default(); + lines.push(format!( + " {}: {}{}{}", + detail.title(), + detail.value(), + secondary, + progress + )); + } +} + fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, use_color: bool) { if window.is_informational { let description = window.reset_description.as_deref().unwrap_or("unavailable"); diff --git a/rust/src/cli/usage_tests.rs b/rust/src/cli/usage_tests.rs index 17acac10fe..9454d89050 100644 --- a/rust/src/cli/usage_tests.rs +++ b/rust/src/cli/usage_tests.rs @@ -308,3 +308,19 @@ fn json_inventory_is_additive_and_contains_no_redemption_token() { .contains("coupon-token-secret") ); } + +#[test] +fn display_details_are_rendered_in_full_text_and_json() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_display_detail( + crate::core::ProviderDisplayDetail::new("credits", "Used this cycle", "12") + .with_secondary_value("Monthly refill: 100") + .with_progress(12.0, 100.0), + ); + + let full = render_text_with_status(ProviderId::Grok, &result, None, false); + let json = render_json_result(ProviderId::Grok, result, None); + + assert!(full.contains("Used this cycle: 12 (Monthly refill: 100) [12.00/100.00]")); + assert_eq!(json["details"][0]["title"], "Used this cycle"); + assert_eq!(json["details"][0]["progress"]["total"], 100.0); +} diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 4e3ec9c023..5f6cf0f8b2 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -105,21 +105,6 @@ pub struct ProviderInventoryItem { pub next_expires_at: Option>, } -/// One display-only item of provider-issued discrete inventory. -/// -/// This is deliberately separate from [`RateWindow`]: inventory does not -/// represent a percentage quota and must not participate in quota arithmetic, -/// tray metric selection, pace, notifications, or auto-resume decisions. -/// Provider-specific redemption identifiers stay private to the provider -/// parser and never enter this type. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProviderInventoryItem { - pub id: String, - pub title: String, - pub available_count: u32, - pub next_expires_at: Option>, -} - /// One transient provider detail row for display surfaces. /// /// These rows are intentionally separate from quota windows and inventory: diff --git a/rust/src/providers/nous/credentials.rs b/rust/src/providers/nous/credentials.rs new file mode 100644 index 0000000000..93e9c46ac2 --- /dev/null +++ b/rust/src/providers/nous/credentials.rs @@ -0,0 +1,308 @@ +//! Nous Portal credential resolution. +//! +//! Reads Hermes-held credentials without refreshing or writing them: +//! explicit token -> environment -> auth files in precedence order, the +//! multi-entry credential pool comparator, portal-origin trust policy, and +//! JWT expiry inspection. + +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use reqwest::Url; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::core::ProviderError; +use crate::providers::validated_https_url; + +pub(super) const DEFAULT_PORTAL_URL: &str = "https://portal.nousresearch.com"; +pub(super) const ACCESS_TOKEN_ENV: &str = "NOUS_PORTAL_ACCESS_TOKEN"; +pub(super) const PORTAL_URL_ENVS: &[&str] = &["NOUS_PORTAL_BASE_URL", "HERMES_PORTAL_BASE_URL"]; +pub(super) const HERMES_HOME_ENV: &str = "HERMES_HOME"; +pub(super) const EXPIRY_SKEW: i64 = 60; +const TRUSTED_PORTAL_HOST: &str = "nousresearch.com"; + +#[derive(Debug, Clone)] +pub(super) struct Credential { + pub(super) token: String, + pub(super) portal_url: Url, + expires_at: Option>, +} + +impl Credential { + fn is_expired(&self, now: DateTime) -> bool { + self.expires_at + .is_some_and(|expires_at| expires_at <= now + ChronoDuration::seconds(EXPIRY_SKEW)) + } +} + +#[derive(Debug, Clone)] +pub(super) struct StoredCredential { + pub(super) token: String, + portal_base_url: Option, + expires_at: Option>, +} + +pub(super) fn resolve_credential( + explicit_token: Option<&str>, +) -> Result { + let environment: HashMap = std::env::vars().collect(); + let home = dirs::home_dir().ok_or_else(missing_credentials)?; + resolve_credential_from(explicit_token, &environment, &home, Utc::now()) +} + +pub(super) fn resolve_credential_from( + explicit_token: Option<&str>, + environment: &HashMap, + home_directory: &Path, + now: DateTime, +) -> Result { + if let Some(token) = cleaned(explicit_token) { + return usable_credential(token, resolve_portal_url(environment, None), now); + } + if let Some(token) = cleaned(environment.get(ACCESS_TOKEN_ENV).map(String::as_str)) { + return usable_credential(token, resolve_portal_url(environment, None), now); + } + + let candidates = auth_file_candidates(environment, home_directory); + let mut saw_file = false; + let mut expired: Option = None; + for path in &candidates { + let Ok(contents) = std::fs::read(path) else { + continue; + }; + saw_file = true; + let Some(stored) = parse_auth_file(&contents) else { + continue; + }; + let credential = Credential { + expires_at: stored.expires_at.or_else(|| jwt_expiry(&stored.token)), + portal_url: resolve_portal_url(environment, stored.portal_base_url.as_deref()), + token: stored.token, + }; + if credential.is_expired(now) { + expired.get_or_insert(credential); + } else { + return Ok(credential); + } + } + + if expired.is_some() { + return Err(ProviderError::OAuthExpired( + "Nous Portal Hermes login expired. Run hermes to refresh it.".to_string(), + )); + } + if saw_file { + return Err(ProviderError::NotInstalled( + "Nous Portal auth files contain no usable login. Run hermes to sign in again." + .to_string(), + )); + } + Err(missing_credentials()) +} + +fn usable_credential( + token: String, + portal_url: Url, + now: DateTime, +) -> Result { + let credential = Credential { + expires_at: jwt_expiry(&token), + token, + portal_url, + }; + if credential.is_expired(now) { + return Err(ProviderError::OAuthExpired( + "Nous Portal access token expired. Run hermes to refresh it.".to_string(), + )); + } + Ok(credential) +} + +fn missing_credentials() -> ProviderError { + ProviderError::NotInstalled( + "Nous Portal login not found. Run hermes to sign in, then refresh CodexBar.".to_string(), + ) +} + +fn auth_file_candidates( + environment: &HashMap, + home_directory: &Path, +) -> Vec { + let root = environment + .get(HERMES_HOME_ENV) + .and_then(|raw| cleaned(Some(raw.as_str()))) + .map(|raw| expand_home(&raw, home_directory)) + .unwrap_or_else(|| { + let home = environment + .get("HOME") + .and_then(|raw| cleaned(Some(raw.as_str()))) + .map(|raw| expand_home(&raw, home_directory)) + .unwrap_or_else(|| home_directory.to_path_buf()); + home.join(".hermes") + }); + vec![ + root.join("auth.json"), + root.join("shared").join("nous_auth.json"), + ] +} + +fn expand_home(raw: &str, home_directory: &Path) -> PathBuf { + if raw == "~" { + return home_directory.to_path_buf(); + } + if let Some(rest) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) { + return home_directory.join(rest); + } + PathBuf::from(raw) +} + +/// Parse one Hermes auth file. Three shapes exist because Hermes evolved; +/// each branch is tried in order and the first match wins: +/// +/// 1. Current: `{"providers": {"nous": {...}}}` — the per-provider state +/// Hermes writes today. +/// 2. Legacy pool: `{"credential_pool": {"nous": [...]}}` — the multi-entry +/// pool the agent key rotation used before the single-provider shape. +/// 3. Oldest fallback: the root object itself is the credential, matching +/// the earliest hand-edited `auth.json` layout. +pub(super) fn parse_auth_file(contents: &[u8]) -> Option { + let root: Value = serde_json::from_slice(contents).ok()?; + let root_object = root.as_object()?; + + if let Some(providers) = root_object.get("providers").and_then(Value::as_object) + && let Some(nous) = providers.get("nous") + && let Some(stored) = stored_credential(nous) + { + return Some(stored); + } + + if let Some(entries) = root_object + .get("credential_pool") + .and_then(Value::as_object) + .and_then(|pool| pool.get("nous")) + .and_then(Value::as_array) + { + return select_pool_credential(entries); + } + + stored_credential(&root) +} + +/// Pool selection order: freshest agent key first, then freshest access +/// token, then *lower* `priority` wins. An entry with no parseable expiry +/// (`unwrap_or(0)`) intentionally ranks below any known expiry — an unknown +/// expiry is treated as the riskiest credential in the pool. +fn select_pool_credential(entries: &[Value]) -> Option { + entries + .iter() + .filter_map(|entry| { + let stored = stored_credential(entry)?; + Some((entry, stored)) + }) + .max_by_key(|(entry, stored)| { + let agent_expiry = entry + .get("agent_key_expires_at") + .and_then(Value::as_str) + .and_then(parse_iso) + .map(|value| value.timestamp()) + .unwrap_or(0); + let access_expiry = stored + .expires_at + .or_else(|| jwt_expiry(&stored.token)) + .map(|value| value.timestamp()) + .unwrap_or(0); + let priority = entry.get("priority").and_then(Value::as_i64).unwrap_or(0); + (agent_expiry, access_expiry, std::cmp::Reverse(priority)) + }) + .map(|(_, stored)| stored) +} + +fn stored_credential(value: &Value) -> Option { + let object = value.as_object()?; + let token = cleaned(object.get("access_token").and_then(Value::as_str))?; + Some(StoredCredential { + token, + portal_base_url: cleaned(object.get("portal_base_url").and_then(Value::as_str)), + expires_at: object + .get("expires_at") + .and_then(Value::as_str) + .and_then(parse_iso), + }) +} + +/// Resolve the portal origin for one fetch. +/// +/// Trust policy is asymmetric by design: a *stored* `portal_base_url` is +/// allow-listed to `nousresearch.com` and its subdomains, but the +/// `NOUS_PORTAL_BASE_URL` / `HERMES_PORTAL_BASE_URL` environment overrides +/// bypass the allow-list entirely and win over stored values. In a local +/// desktop app the operator who can set the process environment already +/// controls the app, so the env override is a developer escape hatch, not a +/// security boundary — the allow-list only protects persisted settings. +pub(super) fn resolve_portal_url( + environment: &HashMap, + stored: Option<&str>, +) -> Url { + for key in PORTAL_URL_ENVS { + if let Some(raw) = environment.get(*key).and_then(|value| cleaned(Some(value))) + && let Ok(url) = validated_https_url(&raw, "Nous Portal") + && is_plain_origin(&url) + { + return url; + } + } + if let Some(raw) = stored.and_then(|value| cleaned(Some(value))) + && let Ok(url) = validated_https_url(&raw, "Nous Portal") + && is_plain_origin(&url) + && is_trusted_portal_host(url.host_str()) + { + return url; + } + Url::parse(DEFAULT_PORTAL_URL).expect("default Nous Portal URL is valid") +} + +/// The canonical validator allows non-empty paths, queries, and fragments; +/// a portal origin is the bare `https://host` root, so pin that on top. +fn is_plain_origin(url: &Url) -> bool { + (url.path().is_empty() || url.path() == "/") + && url.query().is_none() + && url.fragment().is_none() +} + +fn is_trusted_portal_host(host: Option<&str>) -> bool { + let Some(host) = host.map(str::to_ascii_lowercase) else { + return false; + }; + host == TRUSTED_PORTAL_HOST || host.ends_with(&format!(".{TRUSTED_PORTAL_HOST}")) +} + +fn cleaned(value: Option<&str>) -> Option { + let mut value = value?.trim().to_string(); + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + value = value[1..value.len() - 1].trim().to_string(); + } + (!value.is_empty()).then_some(value) +} + +pub(super) fn parse_iso(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|value| value.with_timezone(&Utc)) +} + +fn jwt_expiry(token: &str) -> Option> { + let payload = crate::codex_accounts::api::jwt_payload(token)?; + let seconds = payload.get("exp")?.as_f64()?; + if !seconds.is_finite() || seconds <= 0.0 { + return None; + } + #[allow( + clippy::cast_possible_truncation, + reason = "JWT expiration is converted to whole epoch seconds" + )] + let seconds = seconds.trunc() as i64; + DateTime::from_timestamp(seconds, 0) +} diff --git a/rust/src/providers/nous/mod.rs b/rust/src/providers/nous/mod.rs index 5bce65787a..8879d070d5 100644 --- a/rust/src/providers/nous/mod.rs +++ b/rust/src/providers/nous/mod.rs @@ -6,12 +6,10 @@ //! monthly subscription-credit display used by the rest of the app. use async_trait::async_trait; -use chrono::{DateTime, Datelike, Duration as ChronoDuration, Utc}; +use chrono::{DateTime, Datelike, Utc}; use futures::StreamExt; -use reqwest::{Client, StatusCode, Url}; +use reqwest::{Client, StatusCode}; use serde_json::{Map, Value}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; use tokio::time::{Duration, timeout}; use crate::core::{ @@ -19,36 +17,11 @@ use crate::core::{ ProviderMetadata, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, }; -const DEFAULT_PORTAL_URL: &str = "https://portal.nousresearch.com"; const PORTAL_ACCOUNT_PATH: &str = "api/oauth/account"; -const ACCESS_TOKEN_ENV: &str = "NOUS_PORTAL_ACCESS_TOKEN"; -const PORTAL_URL_ENVS: &[&str] = &["NOUS_PORTAL_BASE_URL", "HERMES_PORTAL_BASE_URL"]; -const HERMES_HOME_ENV: &str = "HERMES_HOME"; const MAX_RESPONSE_BYTES: usize = 512 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); -const EXPIRY_SKEW: i64 = 60; -const TRUSTED_PORTAL_HOST: &str = "nousresearch.com"; - -#[derive(Debug, Clone)] -struct Credential { - token: String, - portal_url: Url, - expires_at: Option>, -} -impl Credential { - fn is_expired(&self, now: DateTime) -> bool { - self.expires_at - .is_some_and(|expires_at| expires_at <= now + ChronoDuration::seconds(EXPIRY_SKEW)) - } -} - -#[derive(Debug, Clone)] -struct StoredCredential { - token: String, - portal_base_url: Option, - expires_at: Option>, -} +mod credentials; pub struct NousProvider { metadata: ProviderMetadata, @@ -69,6 +42,7 @@ impl NousProvider { is_primary: false, dashboard_url: Some("https://portal.nousresearch.com/usage"), status_page_url: None, + tertiary_label_key: None, }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) @@ -81,7 +55,7 @@ impl NousProvider { &self, explicit_token: Option<&str>, ) -> Result { - let credential = resolve_credential(explicit_token)?; + let credential = credentials::resolve_credential(explicit_token)?; let endpoint = credential .portal_url .join(PORTAL_ACCOUNT_PATH) @@ -150,266 +124,6 @@ impl Provider for NousProvider { } } -fn resolve_credential(explicit_token: Option<&str>) -> Result { - let environment: HashMap = std::env::vars().collect(); - let home = dirs::home_dir().ok_or_else(missing_credentials)?; - resolve_credential_from(explicit_token, &environment, &home, Utc::now()) -} - -fn resolve_credential_from( - explicit_token: Option<&str>, - environment: &HashMap, - home_directory: &Path, - now: DateTime, -) -> Result { - if let Some(token) = cleaned(explicit_token) { - return usable_credential(token, resolve_portal_url(environment, None), now); - } - if let Some(token) = cleaned(environment.get(ACCESS_TOKEN_ENV).map(String::as_str)) { - return usable_credential(token, resolve_portal_url(environment, None), now); - } - - let candidates = auth_file_candidates(environment, home_directory); - let mut saw_file = false; - let mut expired: Option = None; - for path in &candidates { - let Ok(contents) = std::fs::read(path) else { - continue; - }; - saw_file = true; - let Some(stored) = parse_auth_file(&contents) else { - continue; - }; - let credential = Credential { - expires_at: stored.expires_at.or_else(|| jwt_expiry(&stored.token)), - portal_url: resolve_portal_url(environment, stored.portal_base_url.as_deref()), - token: stored.token, - }; - if credential.is_expired(now) { - expired.get_or_insert(credential); - } else { - return Ok(credential); - } - } - - if expired.is_some() { - return Err(ProviderError::OAuthExpired( - "Nous Portal Hermes login expired. Run hermes to refresh it.".to_string(), - )); - } - if saw_file { - return Err(ProviderError::NotInstalled( - "Nous Portal auth files contain no usable login. Run hermes to sign in again." - .to_string(), - )); - } - Err(missing_credentials()) -} - -fn usable_credential( - token: String, - portal_url: Url, - now: DateTime, -) -> Result { - let credential = Credential { - expires_at: jwt_expiry(&token), - token, - portal_url, - }; - if credential.is_expired(now) { - return Err(ProviderError::OAuthExpired( - "Nous Portal access token expired. Run hermes to refresh it.".to_string(), - )); - } - Ok(credential) -} - -fn missing_credentials() -> ProviderError { - ProviderError::NotInstalled( - "Nous Portal login not found. Run hermes to sign in, then refresh CodexBar.".to_string(), - ) -} - -fn auth_file_candidates( - environment: &HashMap, - home_directory: &Path, -) -> Vec { - let root = environment - .get(HERMES_HOME_ENV) - .and_then(|raw| cleaned(Some(raw.as_str()))) - .map(|raw| expand_home(&raw, home_directory)) - .unwrap_or_else(|| { - let home = environment - .get("HOME") - .and_then(|raw| cleaned(Some(raw.as_str()))) - .map(|raw| expand_home(&raw, home_directory)) - .unwrap_or_else(|| home_directory.to_path_buf()); - home.join(".hermes") - }); - vec![ - root.join("auth.json"), - root.join("shared").join("nous_auth.json"), - ] -} - -fn expand_home(raw: &str, home_directory: &Path) -> PathBuf { - if raw == "~" { - return home_directory.to_path_buf(); - } - if let Some(rest) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) { - return home_directory.join(rest); - } - PathBuf::from(raw) -} - -fn parse_auth_file(contents: &[u8]) -> Option { - let root: Value = serde_json::from_slice(contents).ok()?; - let root_object = root.as_object()?; - - if let Some(providers) = root_object.get("providers").and_then(Value::as_object) - && let Some(nous) = providers.get("nous") - && let Some(stored) = stored_credential(nous) - { - return Some(stored); - } - - if let Some(entries) = root_object - .get("credential_pool") - .and_then(Value::as_object) - .and_then(|pool| pool.get("nous")) - .and_then(Value::as_array) - { - return select_pool_credential(entries); - } - - stored_credential(&root) -} - -fn select_pool_credential(entries: &[Value]) -> Option { - let mut selected: Option<(StoredCredential, i64, i64, i64)> = None; - for entry in entries { - let Some(stored) = stored_credential(entry) else { - continue; - }; - let agent_expiry = entry - .get("agent_key_expires_at") - .and_then(Value::as_str) - .and_then(parse_iso) - .map(|value| value.timestamp()) - .unwrap_or(0); - let access_expiry = stored - .expires_at - .or_else(|| jwt_expiry(&stored.token)) - .map(|value| value.timestamp()) - .unwrap_or(0); - let priority = entry.get("priority").and_then(Value::as_i64).unwrap_or(0); - let should_replace = - selected - .as_ref() - .is_none_or(|(_, old_agent, old_access, old_priority)| { - agent_expiry > *old_agent - || (agent_expiry == *old_agent - && (access_expiry > *old_access - || (access_expiry == *old_access && priority < *old_priority))) - }); - if should_replace { - selected = Some((stored, agent_expiry, access_expiry, priority)); - } - } - selected.map(|(stored, _, _, _)| stored) -} - -fn stored_credential(value: &Value) -> Option { - let object = value.as_object()?; - let token = cleaned(object.get("access_token").and_then(Value::as_str))?; - Some(StoredCredential { - token, - portal_base_url: cleaned(object.get("portal_base_url").and_then(Value::as_str)), - expires_at: object - .get("expires_at") - .and_then(Value::as_str) - .and_then(parse_iso), - }) -} - -fn resolve_portal_url(environment: &HashMap, stored: Option<&str>) -> Url { - for key in PORTAL_URL_ENVS { - if let Some(raw) = environment.get(*key).and_then(|value| cleaned(Some(value))) - && let Some(url) = normalized_https_url(&raw) - { - return url; - } - } - if let Some(raw) = stored.and_then(|value| cleaned(Some(value))) - && let Some(url) = normalized_https_url(&raw) - && is_trusted_portal_host(url.host_str()) - { - return url; - } - Url::parse(DEFAULT_PORTAL_URL).expect("default Nous Portal URL is valid") -} - -fn normalized_https_url(raw: &str) -> Option { - let value = raw.trim_end_matches('/').trim(); - let url = Url::parse(value).ok()?; - if url.scheme() != "https" - || url.host_str().is_none_or(str::is_empty) - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || (!url.path().is_empty() && url.path() != "/") - { - return None; - } - Some(url) -} - -fn is_trusted_portal_host(host: Option<&str>) -> bool { - let Some(host) = host.map(str::to_ascii_lowercase) else { - return false; - }; - host == TRUSTED_PORTAL_HOST || host.ends_with(&format!(".{TRUSTED_PORTAL_HOST}")) -} - -fn cleaned(value: Option<&str>) -> Option { - let mut value = value?.trim().to_string(); - if value.len() >= 2 - && ((value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\''))) - { - value = value[1..value.len() - 1].trim().to_string(); - } - (!value.is_empty()).then_some(value) -} - -fn parse_iso(value: &str) -> Option> { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|value| value.with_timezone(&Utc)) -} - -fn jwt_expiry(token: &str) -> Option> { - use base64::Engine; - - let payload = token.split('.').nth(1)?; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload) - .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) - .ok()?; - let claims: Value = serde_json::from_slice(&decoded).ok()?; - let seconds = claims.get("exp")?.as_f64()?; - if !seconds.is_finite() || seconds <= 0.0 { - return None; - } - #[allow( - clippy::cast_possible_truncation, - reason = "JWT expiration is converted to whole epoch seconds" - )] - let seconds = seconds.trunc() as i64; - DateTime::from_timestamp(seconds, 0) -} - fn status_error(status: StatusCode) -> ProviderError { match status { StatusCode::UNAUTHORIZED => ProviderError::OAuthExpired( @@ -461,48 +175,27 @@ fn parse_response(body: &[u8]) -> Result { "Invalid Nous Portal account response: expected an object.".to_string(), ) })?; - if root.get("error").is_some_and(is_truthy) { + if root.get("error").is_some_and(reports_error) { return Err(ProviderError::Other( "Nous Portal account endpoint reported an error.".to_string(), )); } - let subscription = optional_object(root.get("subscription"), "subscription")?; let access = optional_object(root.get("paid_service_access"), "paid_service_access")?; let user = optional_object(root.get("user"), "user")?; let organization = optional_object(root.get("organisation"), "organisation")?; - let monthly = number( - subscription.and_then(|value| value.get("monthly_credits")), - "monthly_credits", - )?; + let monthly = field_number(subscription, "monthly_credits")?; if monthly.is_some_and(|value| value < 0.0) { return Err(parse_failure("monthly_credits")); } - let remaining = number( - subscription.and_then(|value| value.get("credits_remaining")), - "credits_remaining", - )? - .or(number( - access.and_then(|value| value.get("subscription_credits_remaining")), - "subscription_credits_remaining", - )?); - let rollover = number( - subscription.and_then(|value| value.get("rollover_credits")), - "rollover_credits", - )?; - let purchased = number( - root.get("purchased_credits_remaining"), - "purchased_credits_remaining", - )? - .or(number( - access.and_then(|value| value.get("purchased_credits_remaining")), - "paid_service_access.purchased_credits_remaining", - )?); - let total = number( - access.and_then(|value| value.get("total_usable_credits")), - "total_usable_credits", + let remaining = first_number( + &[subscription, access], + &["credits_remaining", "subscription_credits_remaining"], )?; + let rollover = field_number(subscription, "rollover_credits")?; + let purchased = first_number(&[Some(root), access], &["purchased_credits_remaining"])?; + let total = field_number(access, "total_usable_credits")?; if [monthly, remaining, rollover, purchased, total] .iter() .all(Option::is_none) @@ -511,7 +204,7 @@ fn parse_response(body: &[u8]) -> Result { } let renewal = optional_date( - subscription.and_then(|value| value.get("current_period_end")), + field(subscription, "current_period_end"), "current_period_end", )?; let primary = if let (Some(monthly), Some(remaining)) = @@ -533,20 +226,14 @@ fn parse_response(body: &[u8]) -> Result { ) }; - let plan = text( - subscription.and_then(|value| value.get("plan")), - "subscription.plan", - )?; + let plan = text(field(subscription, "plan"), "subscription.plan")?; let active_subscription = access .and_then(|value| value.get("has_active_subscription")) .and_then(Value::as_bool) .unwrap_or(false); let login_method = plan.or_else(|| active_subscription.then(|| "Subscription".to_string())); - let email = text(user.and_then(|value| value.get("email")), "user.email")?; - let organization_name = text( - organization.and_then(|value| value.get("name")), - "organisation.name", - )?; + let email = text(field(user, "email"), "user.email")?; + let organization_name = text(field(organization, "name"), "organisation.name")?; let mut usage = UsageSnapshot::new(primary); if let Some(plan) = login_method { @@ -616,6 +303,37 @@ fn parse_response(body: &[u8]) -> Result { Ok(result) } +/// Numeric value of one field on an optional object; the name appears once. +fn field_number( + object: Option<&Map>, + name: &str, +) -> Result, ProviderError> { + number(object.and_then(|object| object.get(name)), name) +} + +/// Read one field from an optional object; the name appears once per call. +fn field<'a>(object: Option<&'a Map>, name: &str) -> Option<&'a Value> { + object.and_then(|object| object.get(name)) +} + +/// First numeric value found for any of `names`, scanned across the +/// response dialects in `sources` order. `sources` names the dialect +/// objects; `names` the per-dialect field names — a rename between +/// dialects stays explicit here. +fn first_number( + sources: &[Option<&Map>], + names: &[&str], +) -> Result, ProviderError> { + for source in sources.iter().flatten() { + for name in names { + if let Some(value) = source.get(*name) { + return number(Some(value), name); + } + } + } + Ok(None) +} + fn optional_object<'a>( value: Option<&'a Value>, field: &str, @@ -658,7 +376,9 @@ fn optional_date( return Ok(None); } let raw = value.as_str().ok_or_else(|| parse_failure(field))?; - parse_iso(raw).map(Some).ok_or_else(|| parse_failure(field)) + credentials::parse_iso(raw) + .map(Some) + .ok_or_else(|| parse_failure(field)) } fn text(value: Option<&Value>, field: &str) -> Result, ProviderError> { @@ -681,12 +401,19 @@ fn text(value: Option<&Value>, field: &str) -> Result, ProviderEr Ok(Some(value.to_string())) } -fn is_truthy(value: &Value) -> bool { +/// Pinned error-field policy, matching the upstream Hermes plugin's +/// JavaScript `if (root.error)` check (Plugins/nous.js in the 0.61.0 port): +/// an error is reported exactly when the field is a non-empty object/array +/// or a non-empty string. Booleans, numbers (including 0), empty strings, +/// and empty arrays are treated as "no error" — the portal never reports +/// errors through numeric or boolean fields, so treating them as truthy +/// here would only manufacture failures the API does not send. +fn reports_error(value: &Value) -> bool { match value { - Value::Null | Value::Bool(false) => false, - Value::Number(value) => value.as_f64().is_none_or(|number| number != 0.0), + Value::Null | Value::Bool(_) | Value::Number(_) => false, Value::String(value) => !value.is_empty(), - Value::Array(_) | Value::Object(_) | Value::Bool(true) => true, + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), } } @@ -714,9 +441,16 @@ fn parse_failure(field: impl Into) -> ProviderError { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; use std::fs; + use std::path::Path; use tempfile::tempdir; + use credentials::{ + ACCESS_TOKEN_ENV, HERMES_HOME_ENV, PORTAL_URL_ENVS, parse_auth_file, + resolve_credential_from, resolve_portal_url, + }; + fn environment(entries: &[(&str, &str)]) -> HashMap { entries .iter() @@ -838,6 +572,39 @@ mod tests { } } + #[test] + fn error_field_is_reported_only_for_meaningful_shapes() { + // Reported: non-empty string, non-empty array, non-empty object. + for payload in [ + serde_json::json!({"error": "invalid token"}), + serde_json::json!({"error": ["details"]}), + serde_json::json!({"error": {"code": 7}}), + ] { + let error = parse_response(&serde_json::to_vec(&payload).unwrap()).unwrap_err(); + assert!( + error.to_string().contains("reported an error"), + "shape {:?} must report an error", + payload + ); + } + // Not reported: null, false, empty string, empty array, empty + // object, zero. The portal reports errors via a truthy `error` field + // (upstream Hermes plugin `if (root.error)`), so these shapes mean + // "no error here". + for payload in [ + serde_json::json!({"subscription": {"monthly_credits": 1}, "error": null}), + serde_json::json!({"subscription": {"monthly_credits": 1}, "error": false}), + serde_json::json!({"subscription": {"monthly_credits": 1}, "error": true}), + serde_json::json!({"subscription": {"monthly_credits": 1}, "error": ""}), + serde_json::json!({"subscription": {"monthly_credits": 1}, "error": []}), + serde_json::json!({"subscription": {"monthly_credits": 1}, "error": {}}), + serde_json::json!({"subscription": {"monthly_credits": 1}, "error": 0}), + ] { + let result = parse_response(&serde_json::to_vec(&payload).unwrap()).unwrap(); + assert_eq!(result.source_label, "api"); + } + } + #[test] fn endpoint_errors_are_classified_without_response_body() { assert!(matches!(