Provider-agnostic LLM registry, config check, migration runbook
[llm] assigns the bulk and editor roles by name over a [providers.*] registry (kind = openai | anthropic, per-provider model, effort, daily ceiling and price table); DeepseekBackend becomes OpenAiCompatibleBackend (reasoning_effort passthrough), AnthropicBackend builds from the same ProviderConfig, meters and provider_costs are keyed by provider name. Gemini 3.8 Flash is declared via Google's OpenAI-compatible endpoint so switching the editor is one line (or DAILY_EPUB_LLM__EDITOR=gemini for an A/B dry run). Stale [deepseek]/[anthropic] tables, the top-level max_daily_usd and the old key env vars fail loudly. daily-epub config check validates and prints the resolved roles, models, key presence and paths without opening the database. docs/runbooks/curation-v2-migration.md walks the server upgrade from v1. Registry implemented by a Claude agent from an orchestrator brief; verified fmt/clippy(-W dead_code)/test green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
//! `daily-epub config check` end to end: the built binary, a temp config, no
|
||||
//! environment. It must validate like `generate`, print the provider table
|
||||
//! with `key MISSING` warnings, exit 0, and exit non-zero on an invalid file —
|
||||
//! all without a database or the run lock.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn run(config_body: &str) -> (i32, String, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, config_body).expect("write config");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_daily-epub"))
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
|
||||
.args(["--config"])
|
||||
.arg(&path)
|
||||
.args(["config", "check"])
|
||||
.output()
|
||||
.expect("run daily-epub");
|
||||
(
|
||||
output.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_check_prints_the_facts_and_exits_zero_without_keys() {
|
||||
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml");
|
||||
let body = std::fs::read_to_string(example).expect("example config");
|
||||
let (code, stdout, stderr) = run(&body);
|
||||
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
|
||||
for needle in [
|
||||
"config: ",
|
||||
"database_path: /var/lib/daily-epub/daily-epub.db",
|
||||
"profile_path: data/profile.md",
|
||||
"interests_opml: data/scour-interests.opml",
|
||||
"llm.bulk: deepseek · openai · deepseek-v4-flash",
|
||||
"key MISSING (set DAILY_EPUB_PROVIDERS__DEEPSEEK__API_KEY)",
|
||||
"llm.editor: anthropic · anthropic · claude-opus-5 · effort high · max_daily_usd $3.00",
|
||||
"key MISSING (set DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY)",
|
||||
"providers.gemini: unreferenced",
|
||||
"voyage: voyage-4-lite · enabled · max_daily_usd $0.50 · key MISSING (set DAILY_EPUB_VOYAGE__API_KEY)",
|
||||
"editorial.summary_model: editor",
|
||||
"publish.epub_dir: /srv/bookorbit/libraries/daily-epub",
|
||||
"publish.xtc_dir: /var/lib/daily-epub/xtc",
|
||||
] {
|
||||
assert!(stdout.contains(needle), "missing {needle:?} in:\n{stdout}");
|
||||
}
|
||||
assert!(
|
||||
stdout.lines().any(|line| line.starts_with("! ")),
|
||||
"missing keys are flagged with a `!` prefix:\n{stdout}"
|
||||
);
|
||||
// No lock file, no database: the command is read-only.
|
||||
assert!(!Path::new("/var/lib/daily-epub/daily-epub.db.lock").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_check_exits_non_zero_on_an_invalid_config() {
|
||||
let (code, stdout, stderr) = run("[llm]\nbulk = \"nope\"\n");
|
||||
assert_ne!(code, 0);
|
||||
assert!(stdout.is_empty(), "{stdout}");
|
||||
assert!(stderr.contains("providers.nope"), "{stderr}");
|
||||
|
||||
let (code, _, stderr) = run("[deepseek]\nmodel = \"x\"\n");
|
||||
assert_ne!(code, 0);
|
||||
assert!(stderr.contains("[providers.deepseek]"), "{stderr}");
|
||||
}
|
||||
Reference in New Issue
Block a user