配置与模型目录
第 8 章你逐行读过 Cargo.toml,那是给 Cargo 看的清单;jackdaw.toml 是给 jackdaw 自己看的配置。读完这章,你应该能回答:
- 把
strategy拼成stratagy,错误在load()的哪一步被抓住?为什么宁可拒绝启动,也不悄悄用默认值? api_key_env = "OPENAI_API_KEY"里存的是名字不是密钥——这换来了哪两条承诺?- thiserror 和 anyhow 同处一个项目,各自的辖区在哪?
从 TOML 到类型
先看输入,jackdaw.toml 的一份样例:
[gateway]
port = 3426
auth_token_env = "JACKDAW_TOKEN"
[providers.openai]
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"
[providers.openrouter]
base_url = "https://openrouter.ai/api/v1"
api_key_env = "OPENROUTER_API_KEY"
[groups.coder]
strategy = "order"
models = ["openrouter/deepseek/deepseek-chat", "openai/gpt-5.2"]
[groups.fast]
strategy = "rotate"
models = ["openai/gpt-5-mini", "openrouter/meta-llama/llama-3.3-70b"]三个顶层表:[gateway] 管端口和令牌,[providers.X] 一张表一个供应商,[groups.coder] 是一个路由组。没有类型这层,代码就得满世界从解析结果里捞字符串,拼错的键会在离起因很远的地方才爆炸。jackdaw 用 serde——第 6 章见过,它做数据结构与文本的互译——把整份文件一次性灌进结构体:
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub gateway: GatewayConfig,
pub providers: BTreeMap<String, ProviderConfig>,
#[serde(default)]
pub groups: BTreeMap<String, GroupConfig>,
}
// ...
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderConfig {
/// OpenAI-compatible base URL, without a trailing slash.
pub base_url: String,
/// Name of the env var holding the provider API key.
pub api_key_env: String,
}读法:Config 的骨架第 6 章拆过,这里只看新成员 ProviderConfig。从 providers 字段看起,类型 BTreeMap<String, ProviderConfig> 意味着 TOML 里每张 [providers.X] 表的表名 X 成为 map 的键,表内字段按 ProviderConfig 的两个 String 填。BTreeMap 是按键排序的集合,遍历顺序稳定,所以列出 provider 的次序不会漂。#[serde(default)] 允许整个 [groups] 段缺席;#[derive(Deserialize)] 是第 6 章的派生在干活。deny_unknown_fields 则是文件头注释里写明的刻意决定:未知字段——最常见的正是拼错的字段——必须响亮地失败,stratagy 死在这一步。
策略只有两个合法值,也是个枚举:
/// How a group picks its starting member. Serde matches the lowercase TOML
/// spelling, so an unknown strategy (say, magpie's `smart`) is a load error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Strategy {
/// Always start at the first member on the list.
Order,
/// Move the starting member forward on every request.
Rotate,
}读法:rename_all = "lowercase" 让变体 Order 匹配 TOML 字符串 "order"。两个变体里,order 总是从列表第一个开始,rotate 每次把起点往后挪一位(轮询)。写 "smart" 会怎样?加载直接失败——magpie 有 smart / usage 等策略,jackdaw 刻意只做这两种。得益于枚举,不支持的策略根本变不成 Config 值。
这份 Config 同时就是模型目录:jackdaw models 的输出不是手工另维护的数据,直接从它生成:
group/coder order openrouter/deepseek/deepseek-chat -> openai/gpt-5.2
group/fast rotate openai/gpt-5-mini -> openrouter/meta-llama/llama-3.3-70b
providers: openai, openrouter (direct reference format: <provider>/<model-id>)读输出:每行一个组——名字、策略、按尝试顺序排列的成员;最后一行是可以直接引用的 provider 名单。配置与目录同源,永远不会漂移。
找文件:两级回退
load() 做四件事:找文件、读字节、解析成类型、语义校验。--config 没给时,第一步就有个问题——去哪找?
fn config_candidates() -> Vec<PathBuf> {
let mut tried = vec![PathBuf::from(LOCAL_CONFIG_NAME)];
match std::env::home_dir() {
Some(home) => tried.push(home.join(".config").join("jackdaw").join("jackdaw.toml")),
None => tried.push(PathBuf::from("~/.config/jackdaw/jackdaw.toml")),
}
tried
}
/// Load, parse and validate a config. `explicit` is the `--config` path.
pub fn load(explicit: Option<&Path>) -> Result<Config, ConfigError> {
let path = match explicit {
Some(given) => Some(given.to_path_buf()),
None => config_candidates().into_iter().find(|path| path.is_file()),
};
let Some(path) = path else {
return Err(ConfigError::no_config(&config_candidates()));
};
let text = std::fs::read_to_string(&path).map_err(|error| ConfigError::Read(path, error))?;
let config: Config =
toml::from_str(&text).map_err(|error| ConfigError::Parse(error.to_string()))?;
config
.validate()
.map_err(|errors| ConfigError::Validation(format!("- {}", errors.join("\n- "))))?;
Ok(config)
}读法:config_candidates() 返回要试的路径——先当前目录的 jackdaw.toml,再 ~/.config/jackdaw/jackdaw.toml,顺序即优先级,项目内的文件盖过用户级的。load() 里显式路径直接用;否则 find 取列表中第一个真实存在的文件。let Some(path) = path else { ... } 是 match 的简写:拿不到 Some 就走 else,把找过的两个路径连同"复制 example 或传 --config"的建议一起写进错误再返回。末尾三步各配一个错误变体——读失败 Read、解析失败 Parse、校验失败 Validation;行尾的 ?(问号运算符)取成功值、把错误提前送回调用方。
validate():把错误攒齐再给你
serde 管形状,管不了语义——组成员指向不存在的 provider、base_url 不是 http(s)、provider 占了保留名 group,这些要自己查:
/// All semantic checks the loader cannot express with types alone.
/// Returns *every* problem found, not just the first, so users fix the
/// whole file in one pass. Each message names the table, the field, the
/// reason, and a suggested fix.
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
// ...
for (id, group) in &self.groups {
if group.models.is_empty() {
errors.push(format!(
"[groups.{id}] models: must list at least one `<provider>/<model-id>` member"
));
}
for member in &group.models {
match ModelRef::parse(member) {
Err(why) => {
errors.push(format!("[groups.{id}] models: member {member:?}: {why}"));
}
// Groups stay flat on purpose (magpie nests up to 8 levels;
// jackdaw keeps one level to stay readable).
Ok(ModelRef::Group { .. }) => {
errors.push(format!(
"[groups.{id}] models: member {member:?} is a group — nested \
groups are not supported. Use `<provider>/<model-id>` members only."
));
}
Ok(ModelRef::Provider { provider, .. }) => {
if !self.providers.contains_key(&provider) {
errors.push(format!(
"[groups.{id}] models: member {member:?} names unknown \
provider `{provider}`. Known providers: {}. Add a \
[providers.{provider}] table or fix the typo.",
self.provider_names().join(", ")
));
}
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}读法:先看签名——错误一侧是 Vec<String> 而不是单个值,检查不因第一个问题中断,而是攒齐整份文件的问题一次修完。内层循环把每个成员交给 ModelRef::parse(解析成 provider 引用或组引用两种之一),再按模式匹配分流:Group 变体说明有人在嵌套组——magpie 支持最多 8 层,jackdaw 刻意只留一层;Provider 变体则去 self.providers 查这个名字在不在。{member:?} 的 :? 要求按 Debug 格式打印,所以字符串带引号。
把成员写成 "nope/gpt-5.2",jackdaw check 的输出:
config validation failed:
- [groups.coder] models: member "nope/gpt-5.2" names unknown provider `nope`. Known providers: openai, openrouter. Add a [providers.nope] table or fix the typo.代码上方的注释给错误信息定了标准:每条说清哪张表、哪个字段、为什么错、建议怎么改。对照输出——[groups.coder] 是表,models 是字段,names unknown provider 是原因,Add a [providers.nope] table or fix the typo 是改法,还附了已知 provider 名单。这就是可行动:拿到信息的人,下一步已经写在里面。
密钥:配置里只存名字
config.rs 开头的模块注释就是设计规则:文件里存的是装密钥的那个环境变量的名字,永远不存密钥本身。jackdaw check 据此只做存在性检查:
/// Verify that every environment variable named by the config actually
/// exists. Presence only — values are never inspected or printed.
pub fn check_env(config: &Config) -> Result<(), ConfigError> {
let missing: Vec<String> = config
.required_env_names()
.into_iter()
.filter(|name| std::env::var(name).is_err())
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(ConfigError::MissingEnv(missing.join(", ")))
}
}读法:required_env_names() 汇总网关的 auth_token_env 加上每个 provider 的 api_key_env;filter 只留下 env::var 读取失败的那些——判断的是"读得到吗",不是"值是什么",注释里的 Presence only 就是这个意思。两条承诺由此兑现:jackdaw.toml 里没有秘密,可以安全提交、分享;jackdaw check 永不把你的 API key 打到终端。对照:magpie 的密钥放在 ~/.config/magpie/providers.json,jackdaw 刻意不这么做。密钥真正被读取,要等发请求那一刻(第 12 章展开)。
thiserror 与 anyhow:错误的分工
第 10 章的 main.rs 里你见过 anyhow:把任何错误包成一份,带着上下文冒到顶层打印退出。那 config.rs 为什么还要自造错误类型?因为 main 要区分退出码——配置和环境问题退 2,其他退 1——要区分,就得有能按变体匹配的枚举:
/// Everything that can go wrong before the gateway opens a socket.
/// `main` maps every variant of this type to exit code 2.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("no config file found — tried:\n{0}")]
MissingConfig(String),
#[error("cannot read config at {0}")]
Read(PathBuf, #[source] std::io::Error),
#[error("cannot parse config:\n{0}")]
Parse(String),
#[error("config validation failed:\n{0}")]
Validation(String),
#[error(
"required environment variable(s) missing: {0}.\njackdaw stores only variable names in its config; export the values and retry."
)]
MissingEnv(String),
}读法:#[derive(Debug, thiserror::Error)] 让这个枚举实现标准库的 Error trait;每个变体上方的 #[error("...")] 是打印模板,{0} 填第一个字段;#[source] 标出底层原因,打印错误链时会带上原始的 io::Error。变体表本身就是"启动前一切能出的事"的清单,顶上的注释写明:main 把每个变体映射到退出码 2。
分工由此清楚:调用方需要区分错误——按变体分支、定退出码、决定要不要重试——用 thiserror 造有形状的枚举;只需要报告错误、一路搬运到顶层的,用 anyhow。jackdaw 的落法:config、provider 这些库性质的模块各自定义 thiserror 错误类型(provider 的错误还要分"值不值得换模型",第 12 章展开),binary 边缘用 anyhow 收口。
小结
stratagy 死在 deny_unknown_fields,smart 死在枚举——非法配置变不成 Config 值,就到不了运行期。找不到文件,错误里列出找过的两个路径;validate() 攒齐全部问题,每条带表、字段、原因、改法;配置只存环境变量名,密钥不落盘、不被打印。thiserror 给错误形状,anyhow 在边缘收口。下一章:Provider 抽象与一次真实的 HTTP 调用,密钥在那里第一次被真正使用。
自检
- 你以前用的某个配置系统对未知字段一律忽略并继续。把
stratagy = "order"写进jackdaw.toml,jackdaw 会怎样?这个行为由哪个属性、在load()的哪一步保证? [groups.coder]的成员写成"nope/gpt-5.2"。写出jackdaw check打印的错误,并指出四要素各对应哪一段。- 假如把
ProviderConfig改成api_key: String直接存密钥,jackdaw check的哪条承诺立刻破产?还有什么连带损失?
参考答案
启动失败,得到 Parse 错误,信息里含
stratagy;由结构体上的#[serde(deny_unknown_fields)]保证,发生在toml::from_str那一步。模块头注释写明这是刻意决定:宁可响亮失败,不悄悄回退默认值。输出:
textconfig validation failed: - [groups.coder] models: member "nope/gpt-5.2" names unknown provider `nope`. Known providers: openai, openrouter. Add a [providers.nope] table or fix the typo.四要素:表
[groups.coder]、字段models、原因names unknown provider、改法Add a [providers.nope] table or fix the typo。"只检查存在、从不检查或打印值"立即破产——
check_env得真的读出值才能工作。连带损失:jackdaw.toml不能进仓库、样例没法直接分享、换密钥要改配置文件而不是重新export。