407 lines
15 KiB
Rust
407 lines
15 KiB
Rust
//! 配置加载。
|
||
//! Configuration loading.
|
||
//!
|
||
//! 读取 `~/.focus/config.json`(可通过 `FOCUS_CONFIG_DIR` 覆盖)。该文件列出
|
||
//! provider 与一个默认值;API-key 的值可以是 `$VAR`/`${VAR}` 模板,在加载时
|
||
//! 解析(见 [`focus_providers::resolve_config_value`])。
|
||
//! Reads `~/.focus/config.json` (overridable via `FOCUS_CONFIG_DIR`). The file
|
||
//! lists providers and a default; API-key values may be `$VAR`/`${VAR}`
|
||
//! templates resolved at load time (see [`focus_providers::resolve_config_value`]).
|
||
//!
|
||
//! 示例配置:
|
||
//! Example config:
|
||
//! ```json
|
||
//! {
|
||
//! "default": "zai-coding-cn",
|
||
//! "providers": {
|
||
//! "zai-coding-cn": {
|
||
//! "kind": "zai",
|
||
//! "region": "cn",
|
||
//! "model": "glm-5.2",
|
||
//! "path": "/api/coding/paas/v4/chat/completions",
|
||
//! "apiKey": "$ZAI_API_KEY"
|
||
//! }
|
||
//! }
|
||
//! }
|
||
//! ```
|
||
//!
|
||
//! `path` 字段用于选择接入档位:对 `xxxxxxxx.yyyyyyyy` 格式的 key 使用 GLM
|
||
//! Coding Plan(`/api/coding/paas/v4/chat/completions`),对按量付费 key 使用
|
||
//! 标准 PaaS(`/api/paas/v4/chat/completions`)。省略时使用 provider 的默认路径。
|
||
//! The `path` field selects the access tier: the GLM Coding Plan
|
||
//! (`/api/coding/paas/v4/chat/completions`) for `xxxxxxxx.yyyyyyyy` keys, or
|
||
//! the standard PaaS (`/api/paas/v4/chat/completions`) for pay-as-you-go keys.
|
||
//! When omitted, the provider's default path is used.
|
||
|
||
use focus_json::{parse, JsonValue};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
/// 加载配置时出现的错误。
|
||
/// Errors while loading config.
|
||
#[derive(Debug)]
|
||
pub enum ConfigError {
|
||
/// I/O 错误(例如读取文件失败)。
|
||
/// An I/O error (e.g. failed to read the file).
|
||
Io(String),
|
||
/// JSON 解析错误。
|
||
/// A JSON parse error.
|
||
Parse(String),
|
||
/// 缺少必需字段。
|
||
/// A required field is missing.
|
||
Missing(String),
|
||
}
|
||
|
||
impl std::fmt::Display for ConfigError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
ConfigError::Io(m) => write!(f, "config io error: {}", m),
|
||
ConfigError::Parse(m) => write!(f, "config parse error: {}", m),
|
||
ConfigError::Missing(m) => write!(f, "config missing: {}", m),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for ConfigError {}
|
||
|
||
/// 已解析的 provider 条目。
|
||
/// A resolved provider entry.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ProviderEntry {
|
||
/// provider 种类(如 `zai`、`openai`、`anthropic`)。
|
||
/// Provider kind (e.g. `zai`, `openai`, `anthropic`).
|
||
pub kind: String,
|
||
/// 国内为 `"cn"`,国际为 `"intl"`(仅 zai 适用)。
|
||
/// `"cn"` for domestic, `"intl"` for international (zai only).
|
||
pub region: String,
|
||
/// 默认模型 id。
|
||
/// The default model id.
|
||
pub model: String,
|
||
/// 已解析的 API key(加载时由 `$VAR`/`${VAR}` 模板展开而来)。
|
||
/// Resolved API key (expanded from `$VAR`/`${VAR}` templates at load time).
|
||
pub api_key: String,
|
||
/// 可选的自定义 base host(覆盖区域默认值)。
|
||
/// Optional custom base host (overrides the region default).
|
||
pub base_url: Option<String>,
|
||
/// 可选的 chat-completions 路径覆盖(例如为 `xxxxxxxx.yyyyyyyy` 格式的 key
|
||
/// 指定 GLM Coding Plan 路径 `/api/coding/paas/v4/chat/completions`)。
|
||
/// 未设置时使用 provider 的默认路径。
|
||
/// Optional chat-completions path override (e.g. the GLM Coding Plan path
|
||
/// `/api/coding/paas/v4/chat/completions` for `xxxxxxxx.yyyyyyyy` keys).
|
||
/// When unset, the provider's default path is used.
|
||
pub path: Option<String>,
|
||
}
|
||
|
||
/// 已加载的配置。
|
||
/// The loaded configuration.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Config {
|
||
/// 默认 provider 名称。
|
||
/// The default provider name.
|
||
pub default: String,
|
||
/// 全部 provider 条目(名称, 条目)的有序列表。
|
||
/// All provider entries as an ordered list of (name, entry).
|
||
pub providers: Vec<(String, ProviderEntry)>,
|
||
}
|
||
|
||
impl Config {
|
||
/// 一个没有任何 provider 的空配置。当配置文件尚不存在时作为回退,
|
||
/// 这样 CLI 可以打印引导信息而不是崩溃。
|
||
/// An empty config with no providers. Used as a fallback when the config
|
||
/// file does not exist yet, so the CLI can print guidance instead of
|
||
/// crashing.
|
||
pub fn empty() -> Self {
|
||
Self {
|
||
default: String::new(),
|
||
providers: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// 未配置任何 provider 时为真。
|
||
/// True if no providers are configured.
|
||
pub fn is_empty(&self) -> bool {
|
||
self.providers.is_empty()
|
||
}
|
||
|
||
/// 返回默认 provider 条目;若默认值未设置或未知则返回 None。
|
||
/// Get the default provider entry, or None if the default is unset/unknown.
|
||
#[cfg(test)]
|
||
pub fn default_provider(&self) -> Option<(&str, &ProviderEntry)> {
|
||
self.providers
|
||
.iter()
|
||
.find(|(name, _)| name == &self.default)
|
||
.map(|(n, e)| (n.as_str(), e))
|
||
}
|
||
|
||
/// 按名称获取 provider 条目。
|
||
/// Get a named provider entry.
|
||
pub fn provider(&self, name: &str) -> Option<&ProviderEntry> {
|
||
self.providers
|
||
.iter()
|
||
.find(|(n, _)| n == name)
|
||
.map(|(_, e)| e)
|
||
}
|
||
}
|
||
|
||
/// 解析配置目录:`$FOCUS_CONFIG_DIR` 或 `~/.focus`。
|
||
/// Resolve the config directory: `$FOCUS_CONFIG_DIR` or `~/.focus`.
|
||
pub fn config_dir() -> Result<PathBuf, ConfigError> {
|
||
if let Ok(dir) = std::env::var("FOCUS_CONFIG_DIR") {
|
||
return Ok(PathBuf::from(dir));
|
||
}
|
||
let home = std::env::var("HOME").map_err(|_| ConfigError::Io("HOME is not set".into()))?;
|
||
Ok(PathBuf::from(home).join(".focus"))
|
||
}
|
||
|
||
/// 从默认路径(`~/.focus/config.json`)加载配置。
|
||
/// Load config from the default path (`~/.focus/config.json`).
|
||
///
|
||
/// 若文件不存在,则返回内置默认 provider(这样 CLI 开箱即用:
|
||
/// `focus login zai` + `focus "你好"`)。文件存在但不可读、格式错误或缺少
|
||
/// 必需字段时仍视为错误。
|
||
/// If the file does not exist, returns the built-in default providers (so the
|
||
/// CLI works out of the box: `focus login zai` + `focus "你好"`). A file that
|
||
/// exists but is unreadable, malformed, or missing required fields is still an
|
||
/// error.
|
||
pub fn load() -> Result<Config, ConfigError> {
|
||
let path = config_dir()?.join("config.json");
|
||
if !path.exists() {
|
||
return Ok(builtin_defaults());
|
||
}
|
||
load_from(&path)
|
||
}
|
||
|
||
/// 当 `config.json` 不存在时使用的内置默认 provider。它们让 focus 对主要目标
|
||
/// (Z.ai / 智谱 GLM Coding Plan)及其它受支持厂商开箱即用。API key 通过
|
||
/// `focus login` 或 `--api-key` 单独提供。
|
||
/// The built-in default providers used when no `config.json` exists. These make
|
||
/// focus work out of the box for the primary target (Z.ai / 智谱 GLM Coding
|
||
/// Plan) plus the other supported vendors. API keys are supplied separately via
|
||
/// `focus login` or `--api-key`.
|
||
///
|
||
/// 默认的 `zai-coding-cn` 指向 **GLM Coding Plan** 档位,该档位的 key
|
||
/// (`xxxxxxxx.yyyyyyyy` 格式)必须使用 `/api/coding/paas/v4/chat/completions`——
|
||
/// 标准 `/api/paas/v4/...` 路径会对这类 key 返回 `429 余额不足`。
|
||
/// The default `zai-coding-cn` targets the **GLM Coding Plan** tier, whose keys
|
||
/// (`xxxxxxxx.yyyyyyyy` format) must use `/api/coding/paas/v4/chat/completions`
|
||
/// — the standard `/api/paas/v4/...` path reports `429 余额不足` for them.
|
||
pub fn builtin_defaults() -> Config {
|
||
let mk = |kind: &str, region: &str, model: &str, base_url: Option<&str>, path: Option<&str>| {
|
||
ProviderEntry {
|
||
kind: kind.to_string(),
|
||
region: region.to_string(),
|
||
model: model.to_string(),
|
||
api_key: String::new(),
|
||
base_url: base_url.map(String::from),
|
||
path: path.map(String::from),
|
||
}
|
||
};
|
||
Config {
|
||
default: "zai-coding-cn".to_string(),
|
||
providers: vec![
|
||
(
|
||
"zai-coding-cn".to_string(),
|
||
mk(
|
||
"zai",
|
||
"cn",
|
||
"glm-5.2",
|
||
None,
|
||
Some(focus_providers::ZAI_CODING_PATH),
|
||
),
|
||
),
|
||
(
|
||
"zai-coding-intl".to_string(),
|
||
mk(
|
||
"zai",
|
||
"intl",
|
||
"glm-5.2",
|
||
None,
|
||
Some(focus_providers::ZAI_CODING_PATH),
|
||
),
|
||
),
|
||
// 标准按量付费 PaaS 档位(与 coding plan 区分开)。
|
||
// Standard pay-as-you-go PaaS tier (separate from the coding plan).
|
||
("zai-cn".to_string(), mk("zai", "cn", "glm-5.2", None, None)),
|
||
(
|
||
"zai-intl".to_string(),
|
||
mk("zai", "intl", "glm-5.2", None, None),
|
||
),
|
||
(
|
||
"anthropic".to_string(),
|
||
mk("anthropic", "", "claude-sonnet-4-5", None, None),
|
||
),
|
||
("openai".to_string(), mk("openai", "", "gpt-4o", None, None)),
|
||
],
|
||
}
|
||
}
|
||
|
||
/// 从显式路径加载配置。便于测试。
|
||
/// Load config from an explicit path. Useful for tests.
|
||
pub fn load_from(path: &Path) -> Result<Config, ConfigError> {
|
||
// 配置文件缺失不是错误——返回空配置,让调用者决定如何引导用户。
|
||
// A missing config file is not an error — return an empty config so the
|
||
// caller can decide how to guide the user.
|
||
if !path.exists() {
|
||
return Ok(Config::empty());
|
||
}
|
||
let text = std::fs::read_to_string(path)
|
||
.map_err(|e| ConfigError::Io(format!("read {}: {}", path.display(), e)))?;
|
||
let value = parse(&text).map_err(|e| ConfigError::Parse(e.to_string()))?;
|
||
parse_config(&value)
|
||
}
|
||
|
||
/// 将解析后的 JSON 值转换为 [`Config`]。
|
||
/// Convert a parsed JSON value into a [`Config`].
|
||
fn parse_config(value: &JsonValue) -> Result<Config, ConfigError> {
|
||
let default = value
|
||
.get_str("default")
|
||
.ok_or_else(|| ConfigError::Missing("missing 'default' provider name".into()))?
|
||
.to_string();
|
||
let providers_obj = value
|
||
.get("providers")
|
||
.ok_or_else(|| ConfigError::Missing("missing 'providers' object".into()))?;
|
||
let entries = match providers_obj {
|
||
JsonValue::Obj(e) => e,
|
||
_ => return Err(ConfigError::Parse("'providers' must be an object".into())),
|
||
};
|
||
let mut providers = Vec::new();
|
||
for (name, entry) in entries {
|
||
let kind = entry
|
||
.get_str("kind")
|
||
.ok_or_else(|| ConfigError::Missing(format!("provider '{}' missing 'kind'", name)))?
|
||
.to_string();
|
||
let region = entry.get_str("region").unwrap_or("cn").to_string();
|
||
let model = entry
|
||
.get_str("model")
|
||
.ok_or_else(|| ConfigError::Missing(format!("provider '{}' missing 'model'", name)))?
|
||
.to_string();
|
||
// apiKey 在 config.json 中是可选的——key 通常通过 `focus login`(auth.json)
|
||
// 或 --api-key 提供。若缺失则默认为空,后续解析会失败并给出友好提示。
|
||
// apiKey is optional in config.json — the key is usually supplied via
|
||
// `focus login` (auth.json) or --api-key. If absent, it defaults to
|
||
// empty and resolution fails later with a helpful message.
|
||
let raw_key = entry.get_str("apiKey").unwrap_or("");
|
||
let api_key = if raw_key.is_empty() {
|
||
String::new()
|
||
} else {
|
||
focus_providers::resolve_config_value(raw_key).map_err(ConfigError::Io)?
|
||
};
|
||
let base_url = entry.get_str("baseUrl").map(String::from);
|
||
let path = entry.get_str("path").map(String::from);
|
||
providers.push((
|
||
name.clone(),
|
||
ProviderEntry {
|
||
kind,
|
||
region,
|
||
model,
|
||
api_key,
|
||
base_url,
|
||
path,
|
||
},
|
||
));
|
||
}
|
||
Ok(Config { default, providers })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::env;
|
||
|
||
#[test]
|
||
fn parses_minimal_config() {
|
||
let text = r#"{
|
||
"default": "zai-coding-cn",
|
||
"providers": {
|
||
"zai-coding-cn": {
|
||
"kind": "zai",
|
||
"region": "cn",
|
||
"model": "glm-5.2",
|
||
"apiKey": "literal-key"
|
||
}
|
||
}
|
||
}"#;
|
||
let v = parse(text).unwrap();
|
||
let cfg = parse_config(&v).unwrap();
|
||
assert_eq!(cfg.default, "zai-coding-cn");
|
||
let (_, entry) = cfg.default_provider().unwrap();
|
||
assert_eq!(entry.kind, "zai");
|
||
assert_eq!(entry.region, "cn");
|
||
assert_eq!(entry.model, "glm-5.2");
|
||
assert_eq!(entry.api_key, "literal-key");
|
||
}
|
||
|
||
#[test]
|
||
fn resolves_env_var_key() {
|
||
env::set_var("FOCUS_CFG_TEST_KEY", "env-secret");
|
||
let text = r#"{
|
||
"default": "zai",
|
||
"providers": {
|
||
"zai": { "kind": "zai", "model": "m", "apiKey": "$FOCUS_CFG_TEST_KEY" }
|
||
}
|
||
}"#;
|
||
let v = parse(text).unwrap();
|
||
let cfg = parse_config(&v).unwrap();
|
||
assert_eq!(cfg.provider("zai").unwrap().api_key, "env-secret");
|
||
}
|
||
|
||
#[test]
|
||
fn provider_without_apikey_is_accepted() {
|
||
// apiKey 是可选的——key 通过 `focus login` 或 --api-key 提供。
|
||
// apiKey is optional — the key is supplied via `focus login` or --api-key.
|
||
let text = r#"{
|
||
"default": "zai-coding-cn",
|
||
"providers": {
|
||
"zai-coding-cn": { "kind": "zai", "region": "cn", "model": "glm-5.2" }
|
||
}
|
||
}"#;
|
||
let v = parse(text).unwrap();
|
||
let cfg = parse_config(&v).unwrap();
|
||
let entry = cfg.provider("zai-coding-cn").unwrap();
|
||
assert_eq!(entry.model, "glm-5.2");
|
||
assert_eq!(entry.api_key, "");
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_missing_default() {
|
||
let v = parse(r#"{"providers":{}}"#).unwrap();
|
||
assert!(parse_config(&v).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_unknown_default() {
|
||
let text = r#"{
|
||
"default": "nope",
|
||
"providers": {
|
||
"zai": { "kind": "zai", "model": "m", "apiKey": "k" }
|
||
}
|
||
}"#;
|
||
let v = parse(text).unwrap();
|
||
let cfg = parse_config(&v).unwrap();
|
||
assert!(cfg.default_provider().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn missing_file_returns_empty_config() {
|
||
// 不存在的路径不是错误——返回空配置,以便 CLI 打印安装引导而不是崩溃。
|
||
// A nonexistent path is NOT an error — returns an empty config so the
|
||
// CLI can print setup guidance instead of crashing.
|
||
let path = Path::new("/nonexistent/focus/config-test-does-not-exist.json");
|
||
assert!(!path.exists());
|
||
let cfg = load_from(path).expect("missing file should not error");
|
||
assert!(cfg.is_empty());
|
||
assert_eq!(cfg.default, "");
|
||
}
|
||
|
||
#[test]
|
||
fn malformed_file_is_an_error() {
|
||
// 文件存在但 JSON 无效才是真正的错误。
|
||
// A file that exists but has invalid JSON is a real error.
|
||
let dir = std::env::temp_dir();
|
||
let path = dir.join("focus-malformed-test.json");
|
||
std::fs::write(&path, "{ this is not valid json").unwrap();
|
||
let result = load_from(&path);
|
||
assert!(result.is_err());
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
}
|