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}");
|
||||
}
|
||||
+11
-11
@@ -570,9 +570,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
usage,
|
||||
);
|
||||
|
||||
let meter = UsageMeter::new(&cfg.deepseek, cfg.max_daily_usd);
|
||||
let meter = UsageMeter::for_provider(&cfg.providers["deepseek"]);
|
||||
let llm = LlmClient::with_backend(
|
||||
&cfg.deepseek.model,
|
||||
&cfg.providers["deepseek"].model,
|
||||
"You are the editor of The Daily EPUB.".into(),
|
||||
meter.clone(),
|
||||
backend.clone(),
|
||||
@@ -642,9 +642,9 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let colophon = Colophon {
|
||||
provider_costs: BTreeMap::from([("deepseek".to_string(), meter.cost_usd())]),
|
||||
models: Models {
|
||||
bulk: cfg.deepseek.model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.deepseek.model),
|
||||
summaries: cfg.deepseek.model.clone(),
|
||||
bulk: cfg.providers["deepseek"].model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.providers["deepseek"].model),
|
||||
summaries: cfg.providers["deepseek"].model.clone(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
@@ -655,7 +655,7 @@ async fn llm_pipeline_runs_against_a_mock_backend() {
|
||||
let mut lineup = lineup;
|
||||
pipeline::apply_summaries(&mut lineup, &editorial_doc);
|
||||
let issue = assemble_build_publish(&db, &cfg, lineup, colophon).await;
|
||||
assert_eq!(issue.colophon.models.bulk, cfg.deepseek.model);
|
||||
assert_eq!(issue.colophon.models.bulk, cfg.providers["deepseek"].model);
|
||||
assert!(issue.colophon.cost_usd > 0.0);
|
||||
}
|
||||
|
||||
@@ -682,9 +682,9 @@ async fn failing_deepseek_still_publishes_with_heuristic_fallbacks() {
|
||||
|
||||
let backend = std::sync::Arc::new(MockBackend::new());
|
||||
let client = LlmClient::with_backend(
|
||||
&cfg.deepseek.model,
|
||||
&cfg.providers["deepseek"].model,
|
||||
"reader profile".into(),
|
||||
UsageMeter::new(&cfg.deepseek, cfg.max_daily_usd),
|
||||
UsageMeter::for_provider(&cfg.providers["deepseek"]),
|
||||
backend.clone(),
|
||||
);
|
||||
let curator = Curator::new(
|
||||
@@ -732,9 +732,9 @@ async fn failing_deepseek_still_publishes_with_heuristic_fallbacks() {
|
||||
Colophon {
|
||||
provider_costs: BTreeMap::new(),
|
||||
models: Models {
|
||||
bulk: cfg.deepseek.model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.deepseek.model),
|
||||
summaries: cfg.deepseek.model.clone(),
|
||||
bulk: cfg.providers["deepseek"].model.clone(),
|
||||
editor: format!("{} (bulk fallback)", cfg.providers["deepseek"].model),
|
||||
summaries: cfg.providers["deepseek"].model.clone(),
|
||||
},
|
||||
entries_fetched: 8,
|
||||
feeds_seen: 8,
|
||||
|
||||
Reference in New Issue
Block a user