Skip to content

配置与模型目录 ​

第 8 章你逐行读过 Cargo.toml,那是给 Cargo 看的清单;jackdaw.toml 是给 jackdaw 自己看的配置。读完这章,你应该能回答:

  • 把 strategy 拼成 stratagy,错误在 load() 的哪一步被抓住?为什么宁可拒绝启动,也不悄悄用默认值?
  • api_key_env = "OPENAI_API_KEY" 里存的是名字不是密钥——这换来了哪两条承诺?
  • thiserror 和 anyhow 同处一个项目,各自的辖区在哪?

从 TOML 到类型 ​

先看输入,jackdaw.toml 的一份样例:

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 章见过,它做数据结构与文本的互译——把整份文件一次性灌进结构体:

rust
#[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 死在这一步。

策略只有两个合法值,也是个枚举:

rust
/// 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 的输出不是手工另维护的数据,直接从它生成:

text
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 没给时,第一步就有个问题——去哪找?

rust
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,这些要自己查:

rust
    /// 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 的输出:

text
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 据此只做存在性检查:

rust
/// 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——要区分,就得有能按变体匹配的枚举:

rust
/// 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 调用,密钥在那里第一次被真正使用。

自检 ​

  1. 你以前用的某个配置系统对未知字段一律忽略并继续。把 stratagy = "order" 写进 jackdaw.toml,jackdaw 会怎样?这个行为由哪个属性、在 load() 的哪一步保证?
  2. [groups.coder] 的成员写成 "nope/gpt-5.2"。写出 jackdaw check 打印的错误,并指出四要素各对应哪一段。
  3. 假如把 ProviderConfig 改成 api_key: String 直接存密钥,jackdaw check 的哪条承诺立刻破产?还有什么连带损失?
参考答案
  1. 启动失败,得到 Parse 错误,信息里含 stratagy;由结构体上的 #[serde(deny_unknown_fields)] 保证,发生在 toml::from_str 那一步。模块头注释写明这是刻意决定:宁可响亮失败,不悄悄回退默认值。

  2. 输出:

    text
    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。

  3. "只检查存在、从不检查或打印值"立即破产——check_env 得真的读出值才能工作。连带损失:jackdaw.toml 不能进仓库、样例没法直接分享、换密钥要改配置文件而不是重新 export。