2026-09-13 07:25:02 +00:00
|
|
|
|
use std::env;
|
2026-09-14 09:08:35 +00:00
|
|
|
|
use std::path::PathBuf;
|
2026-09-13 07:25:02 +00:00
|
|
|
|
|
|
|
|
|
|
#[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.
|
2026-09-13 07:25:02 +00:00
|
|
|
|
pub fn from_env() -> Result<Self, String> {
|
2026-09-14 09:08:35 +00:00
|
|
|
|
load_dotenv_files();
|
2026-09-13 07:25:02 +00:00
|
|
|
|
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()
|
2026-09-13 07:25:02 +00:00
|
|
|
|
})?;
|
|
|
|
|
|
let base_url = env::var("GROKBOY_BASE_URL")
|
|
|
|
|
|
.or_else(|_| env::var("OPENAI_BASE_URL"))
|
|
|
|
|
|
.unwrap_or_else(|_| "https://api.x.ai/v1".into());
|
2026-09-13 07:42:59 +00:00
|
|
|
|
let model = env::var("GROKBOY_MODEL").unwrap_or_else(|_| "grok-4.6".into());
|
2026-09-13 07:25:02 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 07:25:02 +00:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
2026-09-13 07:42:59 +00:00
|
|
|
|
#[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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 07:25:02 +00:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn rejects_missing_key() {
|
|
|
|
|
|
let _ = Config {
|
|
|
|
|
|
api_key: "x".into(),
|
|
|
|
|
|
base_url: "https://api.x.ai/v1".into(),
|
2026-09-13 07:42:59 +00:00
|
|
|
|
model: "grok-4.6".into(),
|
2026-09-13 07:25:02 +00:00
|
|
|
|
};
|
|
|
|
|
|
}
|
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"));
|
|
|
|
|
|
}
|
2026-09-13 07:25:02 +00:00
|
|
|
|
}
|