165 lines
4.8 KiB
Rust
165 lines
4.8 KiB
Rust
use std::env;
|
||
use std::path::PathBuf;
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct Config {
|
||
pub api_key: String,
|
||
pub base_url: String,
|
||
pub model: String,
|
||
}
|
||
|
||
impl Config {
|
||
/// Resolve from environment, then dotenv files, then a logged-in Grok CLI session.
|
||
pub fn from_env() -> Result<Self, String> {
|
||
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"))
|
||
.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,
|
||
})
|
||
}
|
||
}
|
||
|
||
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();
|
||
if !matches!(
|
||
key,
|
||
"GROKBOY_API_KEY"
|
||
| "XAI_API_KEY"
|
||
| "OPENAI_API_KEY"
|
||
| "GROKBOY_BASE_URL"
|
||
| "OPENAI_BASE_URL"
|
||
| "GROKBOY_MODEL"
|
||
) {
|
||
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(),
|
||
};
|
||
}
|
||
|
||
#[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"));
|
||
}
|
||
}
|