use std::time::{Duration, Instant};
use serde::Deserialize;
pub const API_KEY_PREFIX: &str = "roder_";
pub const DEFAULT_WEB_URL: &str = "https://roder.cloud";
const TOKEN_EXCHANGE_PATH: &str = "/api/v1/inference_tokens";
const EXPIRY_MARGIN: Duration = Duration::from_secs(60);
#[derive(Debug, Deserialize)]
struct TokenResponse {
token: String,
#[serde(default)]
expires_in: u64,
}
#[derive(Debug, Deserialize)]
struct TokenError {
#[serde(default)]
error: String,
}
#[derive(Debug, Clone)]
struct CachedToken {
token: String,
expires_at: Instant,
}
impl CachedToken {
fn is_fresh(&self) -> bool {
Instant::now() < self.expires_at
}
}
pub struct RoderCloudTokenSource {
client: reqwest::Client,
web_url: String,
api_key: String,
cached: tokio::sync::Mutex<Option<CachedToken>>,
}
impl RoderCloudTokenSource {
pub fn new(web_url: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
web_url: web_url.into().trim_end_matches('/').to_string(),
api_key: api_key.into(),
cached: tokio::sync::Mutex::new(None),
}
}
pub async fn token(&self) -> anyhow::Result<String> {
let mut cached = self.cached.lock().await;
if let Some(token) = cached.as_ref().filter(|token| token.is_fresh()) {
return Ok(token.token.clone());
}
let fresh = self.exchange().await?;
let token = fresh.token.clone();
*cached = Some(fresh);
Ok(token)
}
pub async fn refresh(&self) -> anyhow::Result<String> {
let mut cached = self.cached.lock().await;
let fresh = self.exchange().await?;
let token = fresh.token.clone();
*cached = Some(fresh);
Ok(token)
}
async fn exchange(&self) -> anyhow::Result<CachedToken> {
let url = format!("{}{TOKEN_EXCHANGE_PATH}", self.web_url);
let response = self
.client
.post(&url)
.bearer_auth(&self.api_key)
.header("content-type", "application/json")
.send()
.await
.map_err(|err| anyhow::anyhow!("roder.cloud token exchange failed at {url}: {err}"))?;
let status = response.status();
let body = response.bytes().await.unwrap_or_default();
if status == reqwest::StatusCode::UNAUTHORIZED {
let detail = serde_json::from_slice::<TokenError>(&body)
.map(|err| err.error)
.unwrap_or_default();
anyhow::bail!(
"roder.cloud rejected the API key ({detail}); create a key at \
{}/teams and set RODER_CLOUD_API_KEY or [providers.roder-cloud].api_key",
self.web_url
);
}
if !status.is_success() {
anyhow::bail!(
"roder.cloud token exchange failed at {url}: HTTP {status}: {}",
String::from_utf8_lossy(&body)
);
}
let parsed: TokenResponse = serde_json::from_slice(&body).map_err(|err| {
anyhow::anyhow!("roder.cloud token exchange returned malformed JSON: {err}")
})?;
if parsed.token.trim().is_empty() {
anyhow::bail!("roder.cloud token exchange returned an empty token");
}
let ttl = Duration::from_secs(parsed.expires_in.max(1));
let expires_at = Instant::now()
+ ttl
.saturating_sub(EXPIRY_MARGIN)
.max(Duration::from_secs(1));
Ok(CachedToken {
token: parsed.token,
expires_at,
})
}
}