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:
2026-09-02 18:44:57 +00:00
co-authored by Claude Fable 5.1
parent 5edbeb509f
commit 99d1338890
19 changed files with 2136 additions and 620 deletions
+3 -3
View File
@@ -481,8 +481,8 @@ pub async fn run(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CurationConfig, DeepseekConfig};
use crate::curate::llm::{MockBackend, UsageMeter};
use crate::config::{CurationConfig, ProviderConfig};
use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::{article, with_social};
use crate::curate::signals::{Neighbour, TopInterest};
use crate::types::{TokenUsage, Triage};
@@ -520,7 +520,7 @@ mod tests {
LlmClient::with_backend(
"deepseek-v4-flash",
"SYSTEM".into(),
UsageMeter::new(&DeepseekConfig::default(), limit_usd),
UsageMeter::with_prices(PriceTable::from(&ProviderConfig::deepseek()), limit_usd),
backend,
)
}
+4 -6
View File
@@ -637,7 +637,7 @@ pub fn select_without_llm(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AnthropicConfig, CurationConfig, DeepseekConfig};
use crate::config::{CurationConfig, ProviderConfig};
use crate::curate::llm::{ChatBackend, LlmClient, MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article;
use crate::curate::signals::{Neighbour, TopInterest};
@@ -696,9 +696,9 @@ mod tests {
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
let prices = if provider == "anthropic" {
PriceTable::anthropic(&AnthropicConfig::default())
PriceTable::from(&ProviderConfig::anthropic())
} else {
PriceTable::deepseek(&DeepseekConfig::default())
PriceTable::from(&ProviderConfig::deepseek())
};
LlmClient::with_backend_options(
provider,
@@ -1079,9 +1079,7 @@ mod tests {
#[tokio::test]
async fn refusal_on_the_editor_falls_back_to_bulk_with_the_same_prompt() {
let editor = Arc::new(MockBackend::new());
editor.push_llm_error(LlmError::Refusal {
provider: "anthropic",
});
editor.push_llm_error(LlmError::refusal("anthropic"));
let bulk = Arc::new(MockBackend::new());
bulk.push(picks_json(5), TokenUsage::default());
let llms = editor_and_bulk(Arc::clone(&editor), Arc::clone(&bulk));
+14 -19
View File
@@ -1,4 +1,4 @@
//! Claude-first summaries and The Brief, with per-call DeepSeek fallback (§14).
//! Editor-first summaries and The Brief, with per-call bulk fallback (§14).
use std::collections::BTreeMap;
use std::fmt::Write as _;
@@ -12,7 +12,6 @@ use crate::config::{EditorialConfig, SummaryModel};
use crate::types::{ArticleId, Editorial, Lineup, Pick};
pub const FALLBACK_SUMMARY_WORDS: usize = 45;
pub const SUMMARY_CONCURRENCY: usize = 4;
pub const SUMMARY_INSTRUCTIONS: &str = "\
TASK: write the newspaper abstract for one article in today's issue.
@@ -102,9 +101,7 @@ pub async fn summarize_article(
let response: SummaryResponse = llm.complete_json(&prompt, temperature).await?;
let summary = response.summary.trim().to_string();
if summary.is_empty() {
return Err(LlmError::EmptyResponse {
provider: llm.provider,
});
return Err(LlmError::empty_response(llm.provider()));
}
Ok(summary)
}
@@ -172,12 +169,17 @@ pub async fn summarize_all(
temperature: f32,
) -> BTreeMap<ArticleId, String> {
let (primary, fallback) = summary_clients(llms, config.summary_model);
// The summary provider's own `max_concurrent_requests` bounds the fan-out.
let concurrency = primary
.map(|client| client.max_concurrent_requests)
.unwrap_or(1)
.max(1);
stream::iter(lineup.picks.iter())
.map(|pick| async move {
let summary = summarize_pick(pick, primary, fallback, config, temperature).await;
(pick.article.id, summary)
})
.buffer_unordered(SUMMARY_CONCURRENCY)
.buffer_unordered(concurrency)
.filter_map(|(id, summary)| async move { summary.map(|summary| (id, summary)) })
.collect()
.await
@@ -232,10 +234,7 @@ pub async fn brief(
) -> Result<String, LlmError> {
let prompt = build_brief_prompt(lineup, summaries);
let Some(primary) = llms.editor_or_bulk() else {
return Err(LlmError::Api {
provider: "editorial",
message: "no provider configured".into(),
});
return Err(LlmError::api("editorial", "no provider configured"));
};
let response = match primary
.complete_json::<BriefResponse>(&prompt, temperature)
@@ -258,9 +257,7 @@ pub async fn brief(
};
let brief = response.brief.trim().to_string();
if brief.is_empty() {
return Err(LlmError::EmptyResponse {
provider: primary.provider,
});
return Err(LlmError::empty_response(primary.provider()));
}
Ok(brief)
}
@@ -381,7 +378,7 @@ pub fn summary_to_html(summary: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AnthropicConfig, DeepseekConfig};
use crate::config::ProviderConfig;
use crate::curate::llm::{ChatBackend, MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article;
use crate::types::TokenUsage;
@@ -420,9 +417,9 @@ mod tests {
fn mock(provider: &'static str, backend: Arc<MockBackend>, limit: f64) -> LlmClient {
let prices = if provider == "anthropic" {
PriceTable::anthropic(&AnthropicConfig::default())
PriceTable::from(&ProviderConfig::anthropic())
} else {
PriceTable::deepseek(&DeepseekConfig::default())
PriceTable::from(&ProviderConfig::deepseek())
};
LlmClient::with_backend_options(
provider,
@@ -534,9 +531,7 @@ mod tests {
r#"{"summary": "Opus wrote this one."}"#,
TokenUsage::default(),
);
editor.push_llm_error(LlmError::Refusal {
provider: "anthropic",
});
editor.push_llm_error(LlmError::refusal("anthropic"));
editor.push(BRIEF_FIXTURE, TokenUsage::default());
let bulk = Arc::new(MockBackend::new());
bulk.push(
+538 -258
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -39,7 +39,7 @@ pub struct Curator {
impl Curator {
/// An empty [`llm::Llms`] corresponds to `--skip-llm`: cheap-signal order is
/// used for selection and feed excerpts stand in for summaries (notes §6).
/// With only `bulk`, every editor call runs on DeepSeek (§4.2).
/// With only `bulk`, every editor call runs on the bulk provider (§4.2).
pub fn new(config: Config, db: Db, llms: llm::Llms) -> Self {
Self { config, db, llms }
}
@@ -67,15 +67,15 @@ impl Curator {
assess::run(
&self.db,
Some(bulk),
&self.config.deepseek.model,
&bulk.model,
candidates,
self.config.deepseek.deep_batch_size,
self.config.deepseek.max_concurrent_requests,
self.config.llm.deep_batch_size,
bulk.max_concurrent_requests,
self.config.curation.ranking.assessment_reuse_days,
rescore,
profile_version,
assessed_at,
self.config.deepseek.score_temperature,
self.config.llm.score_temperature,
&self.config.curation.sections,
)
.await
@@ -137,7 +137,7 @@ impl Curator {
&self.llms,
lineup,
&self.config.editorial,
self.config.deepseek.editorial_temperature,
self.config.llm.editorial_temperature,
)
.await)
}
+2 -2
View File
@@ -624,7 +624,7 @@ mod tests {
use std::sync::Arc;
use super::super::llm::{MockBackend, UsageMeter};
use crate::config::DeepseekConfig;
use crate::config::ProviderConfig;
use crate::types::{RatingEvent, TokenUsage};
let dir = tempfile::tempdir().unwrap();
@@ -667,7 +667,7 @@ mod tests {
let llm = LlmClient::with_backend(
"deepseek-v4-flash",
initial.text,
UsageMeter::new(&DeepseekConfig::default(), 2.0),
UsageMeter::for_provider(&ProviderConfig::deepseek()),
backend.clone(),
);
let rebuilt = rebuild(&db, &llm, &opml, &profile_path, 60).await.unwrap();
+3 -2
View File
@@ -1370,7 +1370,7 @@ mod tests {
"2026-09-01",
"2026-09-01T09:30:00Z",
"2026-09-01T09:45:00Z",
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14}}"#,
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14},"gemini":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.07}}"#,
),
] {
let run_id = db
@@ -1452,8 +1452,9 @@ mod tests {
"exploration rated positively: 1",
"cost per day (anthropic): $0.043",
"cost per day (deepseek): $0.020",
"cost per day (gemini): $0.005",
"cost per day (voyage): $0.001",
"cost per day (total): $0.064",
"cost per day (total): $0.069",
"mean generation time: 15m00s (3 runs)",
] {
assert!(text.contains(line), "missing {line:?} in:\n{text}");
+4 -4
View File
@@ -500,8 +500,8 @@ pub async fn run(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DeepseekConfig;
use crate::curate::llm::{MockBackend, UsageMeter};
use crate::config::ProviderConfig;
use crate::curate::llm::{MockBackend, PriceTable, UsageMeter};
use crate::curate::prefilter::tests::article;
use crate::curate::signals::{Neighbour, TopInterest};
use crate::types::TokenUsage;
@@ -584,11 +584,11 @@ mod tests {
r#"{"articles":[{"id":42,"interest":8,"kind":"essay","why":"first answer"}]}"#,
TokenUsage::default(),
);
let config = DeepseekConfig::default();
let config = ProviderConfig::deepseek();
let llm = LlmClient::with_backend(
&config.model,
"profile".into(),
UsageMeter::new(&config, 10.0),
UsageMeter::with_prices(PriceTable::from(&config), 10.0),
backend.clone(),
);
let pool = HashSet::from([42]);