use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubscriptionProvider {
Claude,
Codex,
Gemini,
Qwen,
}
impl SubscriptionProvider {
pub const ALL: [Self; 4] = [Self::Claude, Self::Codex, Self::Gemini, Self::Qwen];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Gemini => "gemini",
Self::Qwen => "qwen",
}
}
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"claude" | "anthropic" | "claude-code" => Some(Self::Claude),
"codex" | "chatgpt" | "openai-codex" => Some(Self::Codex),
"gemini" | "google" | "code-assist" => Some(Self::Gemini),
"qwen" | "qwen-code" | "dashscope" => Some(Self::Qwen),
_ => None,
}
}
#[must_use]
pub const fn home_subdir(self) -> &'static str {
match self {
Self::Claude => ".claude",
Self::Codex => ".codex",
Self::Gemini => ".gemini",
Self::Qwen => ".qwen",
}
}
#[must_use]
pub const fn home_env(self) -> &'static str {
match self {
Self::Claude => "CLAUDE_CODE_HOME",
Self::Codex => "CODEX_HOME",
Self::Gemini => "GEMINI_HOME",
Self::Qwen => "QWEN_HOME",
}
}
#[must_use]
pub const fn canonical_credential_filename(self) -> &'static str {
match self {
Self::Claude => ".credentials.json",
Self::Codex => "auth.json",
Self::Gemini | Self::Qwen => "oauth_creds.json",
}
}
#[must_use]
pub const fn credential_filenames(self) -> &'static [&'static str] {
match self {
Self::Claude => &[
".credentials.json",
"credentials.json",
"auth.json",
"oauth.json",
"config.json",
],
Self::Codex => &["auth.json"],
Self::Gemini | Self::Qwen => &["oauth_creds.json"],
}
}
#[must_use]
pub const fn default_base_url(self) -> &'static str {
match self {
Self::Claude => "https://api.anthropic.com",
Self::Codex => "https://chatgpt.com/backend-api/codex",
Self::Gemini => "https://cloudcode-pa.googleapis.com",
Self::Qwen => "https://dashscope.aliyuncs.com/compatible-mode/v1",
}
}
#[must_use]
pub fn resolve_home(self, home: &str) -> PathBuf {
self.named_home()
.unwrap_or_else(|| PathBuf::from(home).join(self.home_subdir()))
}
#[must_use]
pub fn named_home(self) -> Option<PathBuf> {
let official_gemini_root = (self == Self::Gemini)
.then(|| std::env::var_os("GEMINI_CLI_HOME"))
.flatten();
Self::named_home_from(
self,
official_gemini_root,
std::env::var_os(self.home_env()),
)
}
fn named_home_from(
provider: Self,
official_gemini_root: Option<std::ffi::OsString>,
legacy_direct_home: Option<std::ffi::OsString>,
) -> Option<PathBuf> {
if provider == Self::Gemini
&& let Some(root) = official_gemini_root.filter(|root| !root.is_empty())
{
return Some(PathBuf::from(root).join(provider.home_subdir()));
}
legacy_direct_home
.filter(|dir| !dir.is_empty())
.map(PathBuf::from)
}
#[must_use]
pub fn conventional_home(self, home: &str) -> PathBuf {
PathBuf::from(home).join(self.home_subdir())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn official_gemini_cli_root_resolves_the_exact_credential_home() {
let home = SubscriptionProvider::named_home_from(
SubscriptionProvider::Gemini,
Some("/tmp/gemini-root".into()),
None,
);
assert_eq!(home, Some(PathBuf::from("/tmp/gemini-root/.gemini")));
}
}
impl std::fmt::Display for SubscriptionProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.pad(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SubscriptionToken {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at_ms: Option<i64>,
pub account_id: Option<String>,
pub resource_url: Option<String>,
}
impl SubscriptionToken {
#[must_use]
pub fn is_expired(&self, now_ms: i64) -> bool {
self.expires_at_ms.is_some_and(|exp| exp <= now_ms)
}
#[must_use]
pub fn base_url(&self, provider: SubscriptionProvider) -> String {
let Some(resource) = self.resource_url.as_deref().filter(|s| !s.is_empty()) else {
return provider.default_base_url().to_string();
};
let with_scheme = if resource.starts_with("http://") || resource.starts_with("https://") {
resource.to_string()
} else {
format!("https://{resource}")
};
if provider == SubscriptionProvider::Qwen
&& !with_scheme.trim_end_matches('/').ends_with("/v1")
{
format!("{}/v1", with_scheme.trim_end_matches('/'))
} else {
with_scheme
}
}
}