LazyBoy2/crates/grokboy-core/src/config.rs

163 lines
4.8 KiB
Rust
Raw Normal View History

use std::env;
2026-09-14 09:08:35 +00:00
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Config {
pub api_key: String,
pub base_url: String,
pub model: String,
}
impl Config {
2026-09-14 09:08:35 +00:00
/// Resolve from environment, then dotenv files, then a logged-in Grok CLI session.
pub fn from_env() -> Result<Self, String> {
2026-09-14 09:08:35 +00:00
load_dotenv_files();
let api_key = env::var("GROKBOY_API_KEY")
.or_else(|_| env::var("XAI_API_KEY"))
.or_else(|_| env::var("OPENAI_API_KEY"))
2026-09-14 09:08:35 +00:00
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(grok_cli_auth_key)
.ok_or_else(|| {
"沒有 API key。設 GROKBOY_API_KEY或 XAI_API_KEY或先用 Grok CLI 登入(~/.grok/auth.json"
.to_string()
})?;
let base_url = env::var("GROKBOY_BASE_URL")
.or_else(|_| env::var("OPENAI_BASE_URL"))
.unwrap_or_else(|_| "https://api.x.ai/v1".into());
let model = env::var("GROKBOY_MODEL").unwrap_or_else(|_| "grok-4.6".into());
Ok(Self {
api_key,
base_url: base_url.trim_end_matches('/').to_string(),
model,
})
}
}
2026-09-14 09:08:35 +00:00
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn load_dotenv_files() {
let mut paths = Vec::new();
if let Ok(cwd) = env::current_dir() {
paths.push(cwd.join(".env"));
}
if let Some(home) = home_dir() {
paths.push(home.join(".grokboy").join(".env"));
paths.push(home.join(".grokboy").join("env"));
}
for path in paths {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
2026-09-15 03:20:42 +00:00
let grokboy = key.starts_with("GROKBOY_");
if !grokboy
&& !matches!(
key,
"XAI_API_KEY" | "OPENAI_API_KEY" | "OPENAI_BASE_URL"
)
{
2026-09-14 09:08:35 +00:00
continue;
}
if env::var_os(key).is_some() {
continue;
}
let value = value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
if !value.is_empty() {
// dotenv only fills keys that are not already in the process environment.
unsafe { env::set_var(key, value) };
}
}
}
}
fn grok_cli_auth_key() -> Option<String> {
let path = home_dir()?.join(".grok").join("auth.json");
let raw = std::fs::read_to_string(path).ok()?;
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
grok_cli_key_from_value(&value)
}
pub(crate) fn grok_cli_key_from_value(value: &serde_json::Value) -> Option<String> {
let obj = value.as_object()?;
let mut newest: Option<(String, String)> = None;
for (_id, entry) in obj {
let Some(key) = entry
.get("key")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
else {
continue;
};
let created = entry
.get("create_time")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if newest
.as_ref()
.is_none_or(|(prev, _)| created.as_str() >= prev.as_str())
{
newest = Some((created, key.to_string()));
}
}
newest.map(|(_, key)| key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_model_is_grok_4_6() {
let cfg = Config {
api_key: "x".into(),
base_url: "https://api.x.ai/v1".into(),
model: "grok-4.6".into(),
};
assert_eq!(cfg.model, "grok-4.6");
}
#[test]
fn rejects_missing_key() {
let _ = Config {
api_key: "x".into(),
base_url: "https://api.x.ai/v1".into(),
model: "grok-4.6".into(),
};
}
2026-09-14 09:08:35 +00:00
#[test]
fn grok_cli_auth_json_yields_key() {
let value = serde_json::json!({
"https://auth.x.ai::older": {
"key": "old-token",
"create_time": "2026-01-01T00:00:00.000000Z"
},
"https://auth.x.ai::newer": {
"key": "new-token",
"create_time": "2026-09-01T00:00:00.000000Z"
}
});
assert_eq!(grok_cli_key_from_value(&value).as_deref(), Some("new-token"));
}
}