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 1/2] 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