use anyhow::Result;
#[cfg(test)]
use bookforge_core::config::TranslationProfile;
use bookforge_core::{
NullProgressSink,
config::{DoubleCheckMode, FallbackScope, ResolvedRunSettings, TranslationConfig},
scheduler::SchedulerConfig,
segment::{Segment, SegmentStatus, build_segments, compute_cache_namespace},
};
use bookforge_epub::read_epub;
#[cfg(test)]
use bookforge_llm::translate_segments;
use bookforge_llm::{
AdaptiveLimiter, LlmError, LlmProvider, MockMode, MockProvider, OpenAiCompatibleConfig,
OpenAiCompatibleProvider, ProviderRateController, QaSegmentReview, RateControllerConfig,
SegmentTranslation, TelemetryLog, TranslationRunConfig, build_translation_batches,
qa_segments_parallel, run_double_check, telemetry_summary, translate_batches_with_callback,
translate_segments_with_callback,
};
use bookforge_store::{CreateJob, JobRecord, JobStore};
use clap::Args;
use std::{path::PathBuf, sync::Arc};
#[cfg(test)]
use crate::LanguageArgs;
use crate::{
ProviderArgs as CliProviderArgs, QaMode,
checkpoint::{CheckpointCommand, CheckpointSender, CheckpointWriter},
default_output_path,
};
pub mod args;
mod cache;
mod checkpointing;
mod reporting;
mod settings;
mod snapshot;
pub use args::TranslateArgs;
pub(crate) use cache::{CacheContext, apply_cached_translations, pending_segments_for_job};
use checkpointing::finalize_writer;
use reporting::print_summary_rebuild_and_report;
use settings::{apply_provider_preset, resolve_settings, retry_amplification_warning};
use snapshot::persist_snapshot;
pub async fn run(
args: TranslateArgs,
cancel_token: tokio_util::sync::CancellationToken,
) -> Result<()> {
let settings = resolve_settings(&args);
let effective_provider = apply_provider_preset(&args.provider, args.provider_preset);
let output = args
.out
.clone()
.unwrap_or_else(|| default_output_path(&args.input, &args.language.target));
let config = TranslationConfig {
source_language: args.language.source.clone(),
target_language: args.language.target.clone(),
provider: effective_provider.provider.clone(),
model: effective_provider.model.clone(),
concurrency: settings.scheduler.concurrency,
max_attempts: settings.scheduler.max_attempts,
output,
};
if human_stdout_enabled(args.ui) {
println!("Input: {}", args.input.display());
println!("Output: {}", config.output.display());
println!("Target: {}", config.target_language);
println!("Provider: {}", config.provider);
println!("Profile: {:?}", args.profile);
println!("Concurrency: {}", config.concurrency);
println!("Batch enabled: {}", settings.batch.enabled);
if settings.batch.enabled {
println!("Batch target tokens: {}", settings.batch.target_tokens);
println!("Batch max items: {}", settings.batch.max_items);
}
}
let reporter = crate::progress::ProgressReporter::spawn(args.ui, args.progress_jsonl.clone());
let progress_sink = reporter.sink();
if let Some(message) = retry_amplification_warning(&settings) {
progress_sink.emit(bookforge_core::ProgressEvent::Warning {
kind: "retry_amplification".to_string(),
message: message.clone(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
eprintln!("warn: {message}");
}
let run_result = async {
match config.provider.as_str() {
"mock" => {
run_mock_translation(
&args.input,
&config,
&effective_provider,
&args,
&settings,
progress_sink,
)
.await
}
"deepseek" | "openrouter" | "openai-compatible" => {
run_openai_compatible_translation(
&args.input,
&config,
&effective_provider,
&args,
&settings,
&cancel_token,
progress_sink,
)
.await
}
_ => {
if human_stdout_enabled(args.ui) {
println!(
"Translation provider '{}' is not implemented yet.",
config.provider
);
}
Ok(())
}
}
}
.await;
finalize_reporter(run_result, reporter).await
}
fn human_stdout_enabled(ui: crate::progress::UiMode) -> bool {
!matches!(
ui,
crate::progress::UiMode::Json | crate::progress::UiMode::Quiet
)
}
async fn finalize_reporter<T>(
result: Result<T, anyhow::Error>,
reporter: crate::progress::ProgressReporter,
) -> Result<T> {
let reporter_result = reporter.shutdown().await;
match (result, reporter_result) {
(Ok(value), Ok(())) => Ok(value),
(Ok(_), Err(e)) => Err(e),
(Err(e), Ok(())) => Err(e),
(Err(main_err), Err(progress_err)) => Err(anyhow::anyhow!(
"{main_err}; additionally progress reporter failed: {progress_err}"
)),
}
}
async fn run_mock_translation(
input: &PathBuf,
config: &TranslationConfig,
provider_args: &CliProviderArgs,
cli_args: &TranslateArgs,
settings: &ResolvedRunSettings,
progress: Arc<dyn bookforge_core::ProgressSink>,
) -> Result<()> {
let started = std::time::Instant::now();
progress.emit(bookforge_core::ProgressEvent::StageStarted {
stage: "read_epub".to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let book = read_epub(input)?;
progress.emit(bookforge_core::ProgressEvent::StageFinished {
stage: "read_epub".to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
progress.emit(bookforge_core::ProgressEvent::StageStarted {
stage: "segmentation".to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let segments = build_segments(&book, &settings.segmentation)?;
progress.emit(bookforge_core::ProgressEvent::SegmentationFinished {
segment_count: segments.len(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let model = config
.model
.clone()
.unwrap_or_else(|| "mock-prefix-target".to_string());
let prompt_version = "v1";
let store = JobStore::open_default()?;
let job = store.create_job(CreateJob {
input,
output: &config.output,
source_lang: config.source_language.as_deref(),
target_lang: &config.target_language,
provider: "mock",
model: &model,
base_url: None,
api_key_env: None,
})?;
if human_stdout_enabled(cli_args.ui) {
println!("Job: {}", job.id);
}
progress.emit(bookforge_core::ProgressEvent::JobCreated {
job_id: job.id.clone(),
input_path: input.display().to_string(),
output_path: config.output.display().to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let cache_namespace = compute_cache_namespace(
settings.segmentation.max_segment_tokens,
settings.segmentation.context_tokens,
settings.profile.namespace_str(),
settings.batch.enabled,
prompt_version,
);
persist_snapshot(
&store,
&job,
input,
&config.output,
provider_args,
cli_args,
settings,
prompt_version,
&cache_namespace,
&model,
None,
None,
)?;
store.insert_segments(
&job.id,
&segments,
prompt_version,
"mock",
&model,
&cache_namespace,
)?;
let run_config = TranslationRunConfig {
source_language: config.source_language.clone(),
target_language: config.target_language.clone(),
provider: "mock".to_string(),
model: model.clone(),
prompt_version: prompt_version.to_string(),
temperature: 0.2,
scheduler: settings.scheduler.clone(),
profile: settings.profile,
model_context_tokens: None,
max_output_tokens: None,
batch_max_output_tokens: None,
compact_prompts: false,
}; let provider = MockProvider::new(mock_mode(&model), &config.target_language);
let mut translations = apply_cached_translations(
&segments,
CacheContext {
store: &store,
job_id: &job.id,
prompt_version,
provider: &config.provider,
model: &model,
source_lang: config.source_language.as_deref(),
target_lang: &config.target_language,
cache_namespace: &cache_namespace,
},
)?;
let pending_segments = pending_segments_for_job(&store, &job.id, &segments)?;
progress.emit(bookforge_core::ProgressEvent::CacheScanFinished {
hits: translations.len(),
misses: pending_segments.len(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let writer = CheckpointWriter::spawn(store.path().to_path_buf(), progress.clone());
let sender = writer.sender();
let translation_result = translate_and_checkpoint(
provider.clone(),
&pending_segments,
&run_config,
CheckpointContext {
store: &store,
job_id: &job.id,
provider: "mock",
model: &model,
prompt_version,
sender: &sender,
},
)
.await;
let fresh_translations = finalize_writer(translation_result, sender, writer).await?;
translations.extend(fresh_translations);
translations.sort_by_key(|translation| translation.ordinal);
let qa_reviews = qa_reviews_for_mode(
provider,
&segments,
&translations,
&run_config,
&settings.qa,
cli_args.qa,
)
.await;
mark_job_finished(&store, &job.id, &translations)?;
print_summary_rebuild_and_report(
&store,
&job,
&book,
&segments,
&translations,
&qa_reviews,
config,
human_stdout_enabled(cli_args.ui),
)?;
let summary = store
.summary(&job.id)?
.ok_or_else(|| anyhow::anyhow!("job '{}' summary unavailable", job.id))?;
progress.emit(bookforge_core::ProgressEvent::ArtifactWritten {
path: config.output.display().to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
progress.emit(bookforge_core::ProgressEvent::TranslationFinished {
succeeded: summary.succeeded,
cached: summary.cached,
needs_review: summary.needs_review,
failed: summary.failed,
input_tokens: summary.input_tokens,
output_tokens: summary.output_tokens,
elapsed_ms: started.elapsed().as_millis() as u64,
timestamp_ms: bookforge_core::progress::now_ms(),
});
Ok(())
}
pub(crate) fn mock_mode(model: &str) -> MockMode {
match model {
"mock-identity" => MockMode::Identity,
"mock-uppercase" => MockMode::Uppercase,
"mock-malformed-json" => MockMode::MalformedJson,
"mock-wrong-segment-id" => MockMode::WrongSegmentId,
_ => MockMode::PrefixTarget,
}
}
async fn run_openai_compatible_translation(
input: &PathBuf,
config: &TranslationConfig,
provider_args: &CliProviderArgs,
cli_args: &TranslateArgs,
settings: &ResolvedRunSettings,
cancel_token: &tokio_util::sync::CancellationToken,
progress: Arc<dyn bookforge_core::ProgressSink>,
) -> Result<()> {
let started = std::time::Instant::now();
let mut provider_config = match provider_config(
&config.provider,
config.model.as_deref(),
provider_args.base_url.as_deref(),
provider_args.api_key_env.as_deref(),
settings.provider.timeout_seconds,
settings.provider.provider_max_attempts,
settings.provider.thinking_disabled,
settings.provider.retry_after_policy,
settings.provider.max_backoff_seconds,
settings.provider.max_idle_per_host,
settings.provider.json_mode,
) {
Ok(cfg) => cfg,
Err(e) => {
if config.provider == "openai-compatible" {
return Err(anyhow::anyhow!(
"--base-url is required for --provider openai-compatible"
));
}
return Err(e);
}
};
provider_config.json_mode = settings.provider.json_mode;
let provider =
OpenAiCompatibleProvider::new_with_cancel(provider_config.clone(), cancel_token.clone())?;
let model = provider.model().to_string();
progress.emit(bookforge_core::ProgressEvent::RuntimeConfigResolved {
profile: format!("{:?}", settings.profile),
provider_preset: cli_args.provider_preset.map(|preset| format!("{preset:?}")),
provider: config.provider.clone(),
model: model.clone(),
concurrency: settings.scheduler.concurrency,
max_attempts: settings.scheduler.max_attempts,
provider_max_attempts: settings.provider.provider_max_attempts,
validation_max_attempts: settings.provider.validation_max_attempts,
retry_after_policy: format!("{:?}", settings.provider.retry_after_policy),
max_backoff_seconds: settings.provider.max_backoff_seconds,
timeout_seconds: settings.provider.timeout_seconds,
batch_enabled: settings.batch.enabled,
batch_target_tokens: settings.batch.target_tokens,
batch_max_items: settings.batch.max_items,
adaptive_batch_sizing: settings.batch.adaptive_sizing,
adaptive_concurrency: settings.adaptive_concurrency,
compact_prompts: settings.compact_prompts,
thinking_disabled: settings.provider.thinking_disabled,
json_mode: format!("{:?}", settings.provider.json_mode),
model_context_tokens: settings.provider.model_context_tokens,
max_output_tokens: settings.provider.max_output_tokens,
batch_max_output_tokens: settings.provider.batch_max_output_tokens,
timestamp_ms: bookforge_core::progress::now_ms(),
});
progress.emit(bookforge_core::ProgressEvent::StageStarted {
stage: "read_epub".to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let book = read_epub(input)?;
progress.emit(bookforge_core::ProgressEvent::StageFinished {
stage: "read_epub".to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
progress.emit(bookforge_core::ProgressEvent::StageStarted {
stage: "segmentation".to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let segments = build_segments(&book, &settings.segmentation)?;
progress.emit(bookforge_core::ProgressEvent::SegmentationFinished {
segment_count: segments.len(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let run_prompt_version = if settings.batch.enabled {
"batch_v1"
} else {
"v1"
};
let store = JobStore::open_default()?;
let job = store.create_job(CreateJob {
input,
output: &config.output,
source_lang: config.source_language.as_deref(),
target_lang: &config.target_language,
provider: &config.provider,
model: &model,
base_url: Some(&provider_config.base_url),
api_key_env: Some(&provider_config.api_key_env),
})?;
if human_stdout_enabled(cli_args.ui) {
println!("Job: {}", job.id);
}
progress.emit(bookforge_core::ProgressEvent::JobCreated {
job_id: job.id.clone(),
input_path: input.display().to_string(),
output_path: config.output.display().to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
let cache_namespace = compute_cache_namespace(
settings.segmentation.max_segment_tokens,
settings.segmentation.context_tokens,
settings.profile.namespace_str(),
settings.batch.enabled,
run_prompt_version,
);
persist_snapshot(
&store,
&job,
input,
&config.output,
provider_args,
cli_args,
settings,
run_prompt_version,
&cache_namespace,
&model,
Some(provider_config.base_url.clone()),
Some(provider_config.api_key_env.clone()),
)?;
store.insert_segments(
&job.id,
&segments,
run_prompt_version,
&config.provider,
&model,
&cache_namespace,
)?;
let run_config = TranslationRunConfig {
source_language: config.source_language.clone(),
target_language: config.target_language.clone(),
provider: config.provider.clone(),
model: model.clone(),
prompt_version: run_prompt_version.to_string(),
temperature: 0.2,
scheduler: settings.scheduler.clone(),
profile: settings.profile,
model_context_tokens: settings.provider.model_context_tokens,
max_output_tokens: settings.provider.max_output_tokens,
batch_max_output_tokens: settings.provider.batch_max_output_tokens,
compact_prompts: settings.compact_prompts,
};
if settings.batch.enabled {
let batch_run_config = TranslationRunConfig {
source_language: run_config.source_language.clone(),
target_language: run_config.target_language.clone(),
provider: run_config.provider.clone(),
model: run_config.model.clone(),
prompt_version: run_prompt_version.to_string(),
temperature: run_config.temperature,
scheduler: SchedulerConfig {
concurrency: run_config.scheduler.concurrency,
max_attempts: settings.provider.provider_max_attempts,
},
profile: settings.profile,
model_context_tokens: settings.provider.model_context_tokens,
max_output_tokens: settings.provider.max_output_tokens,
batch_max_output_tokens: settings.provider.batch_max_output_tokens,
compact_prompts: settings.compact_prompts,
};
let mut translations = apply_cached_translations(
&segments,
CacheContext {
store: &store,
job_id: &job.id,
prompt_version: run_prompt_version,
provider: &config.provider,
model: &model,
source_lang: config.source_language.as_deref(),
target_lang: &config.target_language,
cache_namespace: &cache_namespace,
},
)?;
let hits = translations.len();
let pending_count = segments.len().saturating_sub(hits);
progress.emit(bookforge_core::ProgressEvent::CacheScanFinished {
hits,
misses: pending_count,
timestamp_ms: bookforge_core::progress::now_ms(),
});
let pending_segments = pending_segments_for_job(&store, &job.id, &segments)?;
let writer = CheckpointWriter::spawn(store.path().to_path_buf(), progress.clone());
let sender = writer.sender();
let translation_result = translate_and_checkpoint_batch(
provider.clone(),
&pending_segments,
&batch_run_config,
settings,
CheckpointContext {
store: &store,
job_id: &job.id,
provider: &config.provider,
model: &model,
prompt_version: run_prompt_version,
sender: &sender,
},
progress.clone(),
)
.await;
let fresh_translations = finalize_writer(translation_result, sender, writer).await?;
translations.extend(fresh_translations);
finish_translation_pipeline(
&provider,
cancel_token,
cli_args,
&segments,
&mut translations,
&store,
&job,
run_prompt_version,
settings,
&run_config,
config,
&book,
progress.clone(),
started,
)
.await?;
} else {
let mut translations = apply_cached_translations(
&segments,
CacheContext {
store: &store,
job_id: &job.id,
prompt_version: run_prompt_version,
provider: &config.provider,
model: &model,
source_lang: config.source_language.as_deref(),
target_lang: &config.target_language,
cache_namespace: &cache_namespace,
},
)?;
let hits = translations.len();
let pending_count = segments.len().saturating_sub(hits);
progress.emit(bookforge_core::ProgressEvent::CacheScanFinished {
hits,
misses: pending_count,
timestamp_ms: bookforge_core::progress::now_ms(),
});
let pending_segments = pending_segments_for_job(&store, &job.id, &segments)?;
let writer = CheckpointWriter::spawn(store.path().to_path_buf(), progress.clone());
let sender = writer.sender();
let translation_result = translate_and_checkpoint(
provider.clone(),
&pending_segments,
&run_config,
CheckpointContext {
store: &store,
job_id: &job.id,
provider: &config.provider,
model: &model,
prompt_version: run_prompt_version,
sender: &sender,
},
)
.await;
let fresh_translations = finalize_writer(translation_result, sender, writer).await?;
translations.extend(fresh_translations);
finish_translation_pipeline(
&provider,
cancel_token,
cli_args,
&segments,
&mut translations,
&store,
&job,
run_prompt_version,
settings,
&run_config,
config,
&book,
progress.clone(),
started,
)
.await?;
}
if cancel_token.is_cancelled() {
let _ = store.mark_job_interrupted(&job.id);
eprintln!();
eprintln!("Interrupted by user.");
eprintln!("Your progress has been saved to job: {}", job.id);
eprintln!();
eprintln!("Resume with:");
eprintln!(" bookforge resume {}", job.id);
return Ok(());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn finish_translation_pipeline(
provider: &OpenAiCompatibleProvider,
cancel_token: &tokio_util::sync::CancellationToken,
cli_args: &TranslateArgs,
segments: &[Segment],
translations: &mut Vec<SegmentTranslation>,
store: &JobStore,
job: &JobRecord,
run_prompt_version: &str,
settings: &ResolvedRunSettings,
run_config: &TranslationRunConfig,
config: &TranslationConfig,
book: &bookforge_core::ir::Book,
progress: Arc<dyn bookforge_core::ProgressSink>,
started: std::time::Instant,
) -> Result<()> {
translations.sort_by_key(|t| t.ordinal);
let qa_reviews = qa_reviews_for_mode(
provider.clone(),
segments,
translations,
run_config,
&settings.qa,
cli_args.qa,
)
.await;
let fallback_translations = run_fallback_pass(
provider,
cli_args,
segments,
std::mem::take(translations),
store,
&job.id,
run_prompt_version,
settings,
)
.await?;
*translations = fallback_translations;
run_double_check_pass(
provider,
cancel_token,
cli_args,
segments,
translations,
run_config,
settings,
)
.await?;
mark_job_finished(store, &job.id, translations)?;
print_summary_rebuild_and_report(
store,
job,
book,
segments,
translations,
&qa_reviews,
config,
human_stdout_enabled(cli_args.ui),
)?;
let summary = store
.summary(&job.id)?
.ok_or_else(|| anyhow::anyhow!("job '{}' summary unavailable", job.id))?;
progress.emit(bookforge_core::ProgressEvent::ArtifactWritten {
path: config.output.display().to_string(),
timestamp_ms: bookforge_core::progress::now_ms(),
});
progress.emit(bookforge_core::ProgressEvent::TranslationFinished {
succeeded: summary.succeeded,
cached: summary.cached,
needs_review: summary.needs_review,
failed: summary.failed,
input_tokens: summary.input_tokens,
output_tokens: summary.output_tokens,
elapsed_ms: started.elapsed().as_millis() as u64,
timestamp_ms: bookforge_core::progress::now_ms(),
});
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn provider_config(
provider: &str,
model: Option<&str>,
base_url: Option<&str>,
api_key_env: Option<&str>,
timeout_seconds: u64,
provider_max_attempts: usize,
thinking_disabled: bool,
retry_after_policy: bookforge_core::RetryAfterPolicy,
max_backoff_seconds: u64,
max_idle_per_host: usize,
json_mode: bookforge_core::JsonMode,
) -> Result<OpenAiCompatibleConfig> {
let (default_url, default_key_env, default_model) = match provider {
"deepseek" => (
"https://api.deepseek.com/v1",
"DEEPSEEK_API_KEY",
"deepseek-v4-flash",
),
"openrouter" => (
"https://openrouter.ai/api/v1",
"OPENROUTER_API_KEY",
"openrouter/auto",
),
_ => {
return Err(anyhow::anyhow!(
"--base-url is required for --provider {provider}"
));
}
};
Ok(OpenAiCompatibleConfig {
base_url: base_url
.map(String::from)
.unwrap_or_else(|| default_url.to_string()),
api_key_env: api_key_env
.map(String::from)
.unwrap_or_else(|| default_key_env.to_string()),
model: model
.or(Some(default_model))
.map(String::from)
.unwrap_or_else(|| default_model.to_string()),
timeout_seconds,
provider_max_attempts: provider_max_attempts.max(1),
thinking_disabled,
retry_after_policy,
max_backoff_seconds,
max_idle_per_host,
json_mode,
})
}
pub(crate) async fn translate_and_checkpoint_batch<P>(
provider: P,
segments: &[Segment],
config: &TranslationRunConfig,
settings: &ResolvedRunSettings,
checkpoint: CheckpointContext<'_>,
progress: Arc<dyn bookforge_core::ProgressSink>,
) -> Result<Vec<SegmentTranslation>>
where
P: LlmProvider,
{
let batches = build_translation_batches(segments, &settings.batch, settings.profile);
if batches.is_empty() {
return translate_and_checkpoint(provider, segments, config, checkpoint).await;
}
eprintln!("Batches: {}", batches.len());
use std::sync::Arc;
let telemetry = Arc::new(TelemetryLog::new());
let rate_controller = if settings.adaptive_concurrency {
let limiter = Arc::new(AdaptiveLimiter::new_with_bounds(
settings.scheduler.concurrency.max(1),
1,
(settings.scheduler.concurrency * 4).max(1),
std::time::Duration::from_secs(2),
Some(progress.clone()),
));
Some(Arc::new(ProviderRateController::new(
limiter,
RateControllerConfig::for_target(settings.scheduler.concurrency.max(1)),
)))
} else {
None
};
let mut batch_sizer = settings.batch.adaptive_sizing.then(|| {
bookforge_llm::BatchSizer::with_progress(
settings.batch.target_tokens,
settings.batch.max_items,
progress.clone(),
)
});
let sender = checkpoint.sender.clone();
let (finalized_tx, mut finalized_rx) = tokio::sync::mpsc::channel::<SegmentTranslation>(64);
let checkpoint_handle = {
let sender = sender.clone();
let job_id = checkpoint.job_id.to_string();
let provider_name = checkpoint.provider.to_string();
let model = checkpoint.model.to_string();
let prompt_version = checkpoint.prompt_version.to_string();
tokio::spawn(async move {
while let Some(translation) = finalized_rx.recv().await {
sender
.send(CheckpointCommand::SaveTranslation {
job_id: job_id.clone(),
translation: Box::new(translation),
provider: provider_name.clone(),
model: model.clone(),
prompt_version: prompt_version.clone(),
})
.await
.map_err(|e| {
bookforge_llm::LlmError::Provider(format!("checkpoint send failed: {e}"))
})?;
}
Ok::<(), bookforge_llm::LlmError>(())
})
};
let batch_result = translate_batches_with_callback(
provider,
batches,
segments,
config,
telemetry.clone(),
rate_controller,
batch_sizer.as_mut(),
progress.clone(),
Some(finalized_tx),
|_| Ok(()),
)
.await;
let checkpoint_result = checkpoint_handle.await;
match (batch_result, checkpoint_result) {
(Ok(translations), Ok(Ok(()))) => {
let snapshot = telemetry.snapshot();
if !snapshot.is_empty() {
eprintln!("\n{}", telemetry_summary(&snapshot));
}
Ok(translations)
}
(Ok(_), Ok(Err(e))) | (Err(e @ bookforge_llm::LlmError::Provider(_)), _) => {
let message = format!("batch translation checkpoint failure: {e}");
mark_unfinished_segments_failed(
checkpoint.store,
checkpoint.job_id,
segments,
&message,
)?;
Err(anyhow::anyhow!(message))
}
(_, Err(join_err)) => {
let message = format!("batch checkpoint task panicked: {join_err}");
mark_unfinished_segments_failed(
checkpoint.store,
checkpoint.job_id,
segments,
&message,
)?;
Err(anyhow::anyhow!(message))
}
(Err(error), _) => {
let message = format!("batch translation failed: {error}");
mark_unfinished_segments_failed(
checkpoint.store,
checkpoint.job_id,
segments,
&message,
)?;
Err(anyhow::anyhow!(message))
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_fallback_pass(
primary_provider: &OpenAiCompatibleProvider,
cli_args: &TranslateArgs,
segments: &[Segment],
mut translations: Vec<SegmentTranslation>,
store: &JobStore,
job_id: &str,
prompt_version: &str,
settings: &ResolvedRunSettings,
) -> Result<Vec<SegmentTranslation>> {
if cli_args.fallback_provider.is_none() && cli_args.fallback_model.is_none() {
return Ok(translations);
}
let provider_str = cli_args
.fallback_provider
.as_deref()
.unwrap_or("openrouter");
let model_str = cli_args
.fallback_model
.as_deref()
.unwrap_or(primary_provider.model());
let fallback_config = provider_config(
provider_str,
Some(model_str),
cli_args.fallback_base_url.as_deref(),
cli_args.fallback_api_key_env.as_deref(),
settings.provider.timeout_seconds,
settings.provider.provider_max_attempts,
settings.provider.thinking_disabled,
settings.provider.retry_after_policy,
settings.provider.max_backoff_seconds,
settings.provider.max_idle_per_host,
settings.provider.json_mode,
)?;
let fallback = OpenAiCompatibleProvider::new_with_cancel(
fallback_config,
primary_provider.cancel_token.clone(),
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let fallback_model = fallback.model().to_string();
let candidates: Vec<Segment> = segments
.iter()
.filter(|s| {
let t = translations.iter().find(|t| t.segment_id.0 == s.id.0);
match t {
Some(t) => match cli_args.fallback_only {
FallbackScope::Failed => t.status == SegmentStatus::Failed,
FallbackScope::NeedsReview => t.status == SegmentStatus::NeedsReview,
FallbackScope::FailedAndNeedsReview => {
t.status == SegmentStatus::Failed || t.status == SegmentStatus::NeedsReview
}
},
None => false,
}
})
.cloned()
.collect();
if candidates.is_empty() {
return Ok(translations);
}
println!(
"Fallback: retrying {} segments with {}/{}",
candidates.len(),
provider_str,
fallback_model
);
let run_config = TranslationRunConfig {
source_language: None,
target_language: String::new(),
provider: provider_str.to_string(),
model: fallback_model.clone(),
prompt_version: prompt_version.to_string(),
temperature: 0.2,
scheduler: SchedulerConfig {
concurrency: 1,
max_attempts: settings.provider.provider_max_attempts,
},
profile: settings.profile,
model_context_tokens: settings.provider.model_context_tokens,
max_output_tokens: settings.provider.max_output_tokens,
batch_max_output_tokens: settings.provider.batch_max_output_tokens,
compact_prompts: settings.compact_prompts,
};
let writer = CheckpointWriter::spawn(store.path().to_path_buf(), Arc::new(NullProgressSink));
let sender = writer.sender();
let checkpoint = CheckpointContext {
store,
job_id,
provider: provider_str,
model: &fallback_model,
prompt_version,
sender: &sender,
};
let translation_result =
translate_and_checkpoint(fallback, &candidates, &run_config, checkpoint).await;
let fresh = finalize_writer(translation_result, sender, writer).await?;
for ft in &fresh {
if let Some(existing) = translations
.iter_mut()
.find(|t| t.segment_id.0 == ft.segment_id.0)
{
*existing = ft.clone();
}
}
Ok(translations)
}
async fn run_double_check_pass(
provider: &OpenAiCompatibleProvider,
cancel_token: &tokio_util::sync::CancellationToken,
cli_args: &TranslateArgs,
segments: &[Segment],
translations: &[SegmentTranslation],
config: &TranslationRunConfig,
settings: &ResolvedRunSettings,
) -> Result<()> {
if settings.double_check.mode == DoubleCheckMode::Off {
return Ok(());
}
let dc_provider =
if cli_args.double_check_provider.is_some() || cli_args.double_check_model.is_some() {
let provider_str = cli_args
.double_check_provider
.as_deref()
.unwrap_or("openrouter");
let dc_config = provider_config(
provider_str,
cli_args.double_check_model.as_deref(),
cli_args.double_check_base_url.as_deref(),
cli_args.double_check_api_key_env.as_deref(),
settings.provider.timeout_seconds,
settings.provider.provider_max_attempts,
settings.provider.thinking_disabled,
settings.provider.retry_after_policy,
settings.provider.max_backoff_seconds,
settings.provider.max_idle_per_host,
settings.provider.json_mode,
)?;
OpenAiCompatibleProvider::new_with_cancel(dc_config, cancel_token.clone())
.map_err(|e| anyhow::anyhow!("{e}"))?
} else {
provider.clone()
};
println!("Double-check: auditing translations...");
let corrections = run_double_check(
dc_provider,
segments,
translations,
config,
&settings.double_check,
)
.await
.map_err(|e| anyhow::anyhow!("double-check failed: {e}"))?;
let applied = corrections
.iter()
.filter(|c| matches!(c.status, bookforge_llm::CorrectionStatus::Applied))
.count();
let rejected = corrections
.iter()
.filter(|c| {
matches!(
c.status,
bookforge_llm::CorrectionStatus::RejectedValidationFailed(_)
)
})
.count();
let unresolved = corrections
.iter()
.filter(|c| matches!(c.status, bookforge_llm::CorrectionStatus::Unresolved))
.count();
println!(" Corrections: {applied} applied, {rejected} rejected, {unresolved} unresolved");
Ok(())
}
#[cfg(test)]
pub(crate) async fn translate_with_scheduler_guard<P>(
provider: P,
store: &JobStore,
job_id: &str,
segments: &[Segment],
config: &TranslationRunConfig,
) -> Result<Vec<SegmentTranslation>>
where
P: LlmProvider,
{
match translate_segments(provider, segments, config).await {
Ok(translations) => Ok(translations),
Err(error) => {
let message = format!(
"translation scheduler failed before producing per-segment results: {error}"
);
mark_unfinished_segments_failed(store, job_id, segments, &message)?;
Err(anyhow::anyhow!(message))
}
}
}
#[derive(Clone)]
pub(crate) struct CheckpointContext<'a> {
pub store: &'a JobStore,
pub job_id: &'a str,
pub provider: &'a str,
pub model: &'a str,
pub prompt_version: &'a str,
pub sender: &'a CheckpointSender,
}
pub(crate) async fn translate_and_checkpoint<P>(
provider: P,
segments: &[Segment],
config: &TranslationRunConfig,
checkpoint: CheckpointContext<'_>,
) -> Result<Vec<SegmentTranslation>>
where
P: LlmProvider,
{
let sender = checkpoint.sender.clone();
let (finalized_tx, mut finalized_rx) = tokio::sync::mpsc::channel::<SegmentTranslation>(64);
let checkpoint_handle = {
let sender = sender.clone();
let job_id = checkpoint.job_id.to_string();
let provider_name = checkpoint.provider.to_string();
let model = checkpoint.model.to_string();
let prompt_version = checkpoint.prompt_version.to_string();
tokio::spawn(async move {
while let Some(translation) = finalized_rx.recv().await {
sender
.send(CheckpointCommand::SaveTranslation {
job_id: job_id.clone(),
translation: Box::new(translation),
provider: provider_name.clone(),
model: model.clone(),
prompt_version: prompt_version.clone(),
})
.await
.map_err(|e| {
bookforge_llm::LlmError::Provider(format!("checkpoint send failed: {e}"))
})?;
}
Ok::<(), bookforge_llm::LlmError>(())
})
};
let translations = translate_segments_with_callback(
provider,
segments,
config,
|_| Ok(()),
Some(finalized_tx),
)
.await;
let checkpoint_result = checkpoint_handle.await;
match (translations, checkpoint_result) {
(Ok(translations), Ok(Ok(()))) => Ok(translations),
(Ok(_), Ok(Err(e))) | (Err(e @ bookforge_llm::LlmError::Provider(_)), _) => {
let message = format!("translation checkpoint failure: {e}");
mark_unfinished_segments_failed(
checkpoint.store,
checkpoint.job_id,
segments,
&message,
)?;
Err(anyhow::anyhow!(message))
}
(_, Err(join_err)) => {
let message = format!("checkpoint task panicked: {join_err}");
mark_unfinished_segments_failed(
checkpoint.store,
checkpoint.job_id,
segments,
&message,
)?;
Err(anyhow::anyhow!(message))
}
(Err(error), _) => {
let message = format!(
"translation scheduler failed before producing per-segment results: {error}"
);
mark_unfinished_segments_failed(
checkpoint.store,
checkpoint.job_id,
segments,
&message,
)?;
Err(anyhow::anyhow!(message))
}
}
}
pub(crate) async fn qa_reviews_for_mode<P>(
provider: P,
segments: &[Segment],
translations: &[SegmentTranslation],
config: &TranslationRunConfig,
qa_config: &bookforge_core::config::QaRunConfig,
qa_mode: QaMode,
) -> Vec<QaSegmentReview>
where
P: LlmProvider,
{
match qa_mode {
QaMode::Off => Vec::new(),
QaMode::All => {
qa_segments_parallel(provider, segments, translations, config, qa_config).await
}
QaMode::Suspicious => {
let candidates = suspicious_qa_candidates(segments, translations);
qa_segments_parallel(provider, segments, &candidates, config, qa_config).await
}
}
}
fn suspicious_qa_candidates(
segments: &[Segment],
translations: &[SegmentTranslation],
) -> Vec<SegmentTranslation> {
let by_segment = segments
.iter()
.map(|segment| (segment.id.0.as_str(), segment))
.collect::<std::collections::HashMap<_, _>>();
translations
.iter()
.filter(|translation| translation.status == SegmentStatus::Succeeded)
.filter(|translation| {
let Some(segment) = by_segment.get(translation.segment_id.0.as_str()) else {
return false;
};
let source_len = segment.source.text.chars().count().max(1);
let translated_len = translation.joined_text().chars().count();
let ratio = translated_len as f64 / source_len as f64;
!(0.5..=2.2).contains(&ratio)
|| translation.template == "translate_run_preserving"
|| segment.constraints.preserve_spans.len() >= 4
|| !segment.constraints.preserve_markers.is_empty()
})
.cloned()
.collect()
}
fn mark_unfinished_segments_failed(
store: &JobStore,
job_id: &str,
segments: &[Segment],
error: &str,
) -> Result<()> {
let segment_ids = segments
.iter()
.map(|segment| segment.id.0.clone())
.collect::<Vec<_>>();
store.mark_unfinished_segments_failed(job_id, &segment_ids, error)?;
Ok(())
}
pub(crate) fn mark_job_finished(
store: &JobStore,
job_id: &str,
translations: &[SegmentTranslation],
) -> Result<()> {
if translations
.iter()
.any(|translation| translation.status == SegmentStatus::Failed)
{
store.mark_job_needs_review(job_id)?;
return Ok(());
}
if translations
.iter()
.any(|translation| translation.status == SegmentStatus::NeedsReview)
{
store.mark_job_needs_review(job_id)?;
return Ok(());
}
store.mark_job_complete(job_id)?;
Ok(())
}
#[derive(Debug, Args)]
pub struct BenchmarkArgs {
#[command(flatten)]
pub provider: CliProviderArgs,
#[arg(long, default_value_t = 5)]
pub samples: usize,
#[arg(long, default_value_t = 1000)]
pub tokens: usize,
#[arg(long, default_value_t = 1)]
pub concurrency: usize,
}
pub async fn run_benchmark(args: BenchmarkArgs) -> Result<()> {
let pigeon = "Sunt piger, et volare nequeunt. Sed cum cibus apparet, mirabiliter currunt.";
let provider_config = OpenAiCompatibleConfig {
base_url: args
.provider
.base_url
.clone()
.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()),
api_key_env: args
.provider
.api_key_env
.clone()
.unwrap_or_else(|| "OPENROUTER_API_KEY".to_string()),
model: args
.provider
.model
.clone()
.unwrap_or_else(|| "openrouter/auto".to_string()),
timeout_seconds: args.provider.timeout_seconds.unwrap_or(120),
provider_max_attempts: 6,
thinking_disabled: false,
retry_after_policy: bookforge_core::RetryAfterPolicy::JitteredExponential,
max_backoff_seconds: 30,
max_idle_per_host: 32,
json_mode: bookforge_core::JsonMode::Auto,
};
let provider = OpenAiCompatibleProvider::new(provider_config.clone())?;
let model = provider.model().to_string();
println!("Benchmarking {} / {}", provider_config.base_url, model);
println!(
"Samples: {}, Tokens: {}, Concurrency: {}",
args.samples, args.tokens, args.concurrency
);
println!();
let mut latencies = Vec::with_capacity(args.samples);
let mut success_count = 0usize;
let mut failure_count = 0usize;
let mut ratelimit_count = 0usize;
let mut timeout_count = 0usize;
let mut total_output_tokens = 0u64;
let mut _total_input_tokens = 0u64;
for i in 0..args.samples {
let request = bookforge_llm::CompletionRequest {
system: "You are a translator. Return JSON only: {\"translation\":\"...\"}".to_string(),
user: format!("Translate: {{\"text\":\"{}\"}} Return JSON.", pigeon),
response_format: bookforge_llm::ResponseFormat::Json,
temperature: 0.2,
max_output_tokens: Some(args.tokens as u32),
metadata: Default::default(),
};
print!(" [{}/{}] ", i + 1, args.samples);
match provider.complete(request).await {
Ok(resp) => {
latencies.push(resp.provider_latency_ms);
success_count += 1;
total_output_tokens += resp.output_tokens.unwrap_or(0);
_total_input_tokens += resp.input_tokens.unwrap_or(0);
let tok_sec = if resp.provider_latency_ms > 0 {
resp.output_tokens.unwrap_or(0) as f64
/ (resp.provider_latency_ms as f64 / 1000.0)
} else {
0.0
};
println!(
"OK {}ms finish={:?} in={:?} out={:?} ~{tok_sec:.0}tok/s",
resp.provider_latency_ms,
resp.finish_reason,
resp.input_tokens,
resp.output_tokens
);
}
Err(e) => {
failure_count += 1;
let kind = classify_error(&e);
match kind {
"rate_limit" => ratelimit_count += 1,
"timeout" => timeout_count += 1,
_ => {}
}
println!("FAIL [{kind}] {e}");
}
}
}
println!();
println!("Results:");
println!(" Success: {} / {}", success_count, args.samples);
println!(" Failed: {}", failure_count);
if !latencies.is_empty() {
latencies.sort();
let p50 = percentile(&latencies, 50);
let p95 = percentile(&latencies, 95);
let avg = latencies.iter().sum::<u64>() as f64 / latencies.len() as f64;
let avg_tok_sec = if avg > 0.0 {
total_output_tokens as f64 / (avg * latencies.len() as f64 / 1000.0)
} else {
0.0
};
println!(" p50 latency: {}ms", p50);
println!(" p95 latency: {}ms", p95);
println!(" avg latency: {:.0}ms", avg);
println!(" avg output: {:.0} tok/s", avg_tok_sec);
}
println!(" 429 count: {}", ratelimit_count);
println!(" timeout count: {}", timeout_count);
if !latencies.is_empty() {
let p50 = percentile(&latencies, 50);
let recommendation = if ratelimit_count > 0 || p50 > 120_000 {
("free-tier", 1usize, 300u64)
} else if p50 < 15_000 && ratelimit_count == 0 {
("fastest", 32usize, 120u64)
} else {
("balanced", 16usize, 120u64)
};
println!();
println!("Recommendation:");
println!(" profile: {}", recommendation.0);
println!(" concurrency: {}", recommendation.1);
println!(" timeout: {}s", recommendation.2);
}
Ok(())
}
fn classify_error(e: &LlmError) -> &'static str {
match e {
LlmError::Http(http_err) => {
if http_err.is_timeout() {
"timeout"
} else {
"http"
}
}
LlmError::HttpStatus { status, .. } if *status == 429 => "rate_limit",
LlmError::HttpStatus { status, .. } if (500..600).contains(status) => "server",
LlmError::HttpStatus { .. } => "client",
LlmError::Provider(_) => "provider",
LlmError::InvalidResponse(_) => "invalid_response",
LlmError::Json(_) => "json",
}
}
fn percentile(data: &[u64], pct: usize) -> u64 {
if data.is_empty() {
return 0;
}
let idx = ((pct as f64 / 100.0) * (data.len() - 1) as f64).round() as usize;
data[idx.min(data.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
use bookforge_core::{
ir::{BlockId, SectionId},
segment::{
SegmentBlock, SegmentConstraints, SegmentContext, SegmentId, SegmentMetadata,
SegmentSource, SegmentTextRun,
},
};
use std::{fs, time::SystemTime};
#[tokio::test]
async fn scheduler_guard_preserves_completed_segments_on_run_level_error() {
let db_path = temp_path("jobs.sqlite");
let input_path = temp_path("input.epub");
fs::write(&input_path, b"epub bytes").expect("input fixture should be writable");
let store = JobStore::open(&db_path).expect("store should open");
let job = store
.create_job(CreateJob {
input: &input_path,
output: &temp_path("output.epub"),
source_lang: Some("English"),
target_lang: "Italian",
provider: "mock",
model: "mock-prefix",
base_url: None,
api_key_env: None,
})
.expect("job should be created");
let segments = vec![segment("seg_a", 0), segment("seg_b", 1)];
store
.insert_segments(&job.id, &segments, "v1", "mock", "mock-prefix", "test_ns")
.expect("segments should insert");
store
.save_translation(bookforge_store::SaveTranslation {
job_id: &job.id,
segment_id: "seg_a",
translated_text: "Gia fatto",
blocks: &[],
input_tokens: Some(1),
input_cached_tokens: Some(0),
output_tokens: Some(2),
tokens_estimated: false,
provider: "mock",
model: "mock-prefix",
prompt_version: "v1",
})
.expect("completed segment should save");
let config = TranslationRunConfig {
source_language: Some("English".to_string()),
target_language: "Italian".to_string(),
provider: "mock".to_string(),
model: "mock-prefix".to_string(),
prompt_version: "v1".to_string(),
temperature: 0.2,
scheduler: SchedulerConfig {
concurrency: 0,
max_attempts: 1,
},
profile: TranslationProfile::Balanced,
model_context_tokens: None,
max_output_tokens: None,
batch_max_output_tokens: None,
compact_prompts: false,
};
let error = translate_with_scheduler_guard(
MockProvider::new(MockMode::PrefixTarget, "Italian"),
&store,
&job.id,
&segments,
&config,
)
.await
.expect_err("zero concurrency is a scheduler-level error");
assert!(
error
.to_string()
.contains("before producing per-segment results")
);
let summary = store
.summary(&job.id)
.expect("summary should load")
.expect("job should exist");
assert_eq!(summary.failed, 1);
assert_eq!(summary.succeeded, 1);
let _ = fs::remove_file(db_path);
let _ = fs::remove_file(input_path);
}
fn segment(id: &str, ordinal: usize) -> Segment {
let block_id = BlockId(format!("b_{ordinal:06}"));
Segment {
id: SegmentId(id.to_string()),
section_id: SectionId("sec_000000".to_string()),
ordinal,
block_ids: vec![block_id.clone()],
source: SegmentSource {
text: format!("Source {ordinal}"),
blocks: vec![SegmentBlock {
block_id,
kind: "paragraph".to_string(),
text: format!("Source {ordinal}"),
text_runs: vec![SegmentTextRun {
id: format!("r{ordinal}"),
text: format!("Source {ordinal}"),
}],
protected_spans: Vec::new(),
}],
token_estimate: 2,
},
context: SegmentContext::default(),
metadata: SegmentMetadata::default(),
constraints: SegmentConstraints::default(),
checksum: format!("checksum_{ordinal}"),
}
}
fn temp_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!(
"bookforge-cli-test-{}-{nanos}-{name}",
std::process::id()
))
}
#[test]
fn provider_config_sets_provider_max_attempts() {
use bookforge_core::RetryAfterPolicy;
let cfg = provider_config(
"openrouter",
None,
None,
None,
120,
2,
false,
RetryAfterPolicy::JitteredExponential,
30,
32,
bookforge_core::JsonMode::Auto,
)
.expect("provider_config should build");
assert_eq!(cfg.provider_max_attempts, 2);
let cfg = provider_config(
"openrouter",
None,
None,
None,
120,
0,
false,
RetryAfterPolicy::JitteredExponential,
30,
32,
bookforge_core::JsonMode::Auto,
)
.expect("provider_config should build");
assert_eq!(cfg.provider_max_attempts, 1);
}
#[test]
fn provider_config_uses_resolved_json_mode() {
let cfg = provider_config(
"openrouter",
None,
None,
None,
120,
1,
true,
bookforge_core::RetryAfterPolicy::JitteredExponential,
15,
64,
bookforge_core::JsonMode::PromptOnly,
)
.expect("provider_config should build");
assert_eq!(cfg.json_mode, bookforge_core::JsonMode::PromptOnly);
}
#[test]
fn retry_amplification_warning_emitted_for_high_attempt_product() {
let mut settings = TranslationProfile::Safe.resolve();
settings.scheduler.max_attempts = 3;
settings.provider.provider_max_attempts = 2;
settings.provider.validation_max_attempts = 1;
let warning = retry_amplification_warning(&settings).expect("warning expected");
assert!(warning.contains("scheduler attempts 3 x provider attempts 2"));
assert!(warning.contains("up to 6 calls"));
}
fn translate_args_with_preset(
provider_preset: Option<bookforge_core::ProviderPreset>,
) -> TranslateArgs {
TranslateArgs {
input: temp_path("input.epub"),
language: LanguageArgs {
source: Some("English".to_string()),
target: "Italian".to_string(),
},
provider: CliProviderArgs {
provider: "deepseek".to_string(),
model: None,
base_url: None,
api_key_env: None,
timeout_seconds: None,
},
profile: TranslationProfile::V1Fast,
max_segment_tokens: None,
context_tokens: None,
batch_target_tokens: None,
batch_max_items: None,
compact_prompts: None,
retry_failed_only: None,
adaptive_concurrency: None,
turbo_text_only: false,
concurrency: None,
max_attempts: None,
provider_max_attempts: None,
validation_max_attempts: None,
out: None,
qa: QaMode::Off,
qa_concurrency: 8,
qa_batch_target_tokens: None,
qa_model: None,
qa_provider: None,
qa_base_url: None,
qa_api_key_env: None,
double_check: DoubleCheckMode::Off,
double_check_model: None,
double_check_provider: None,
double_check_base_url: None,
double_check_api_key_env: None,
double_check_concurrency: 4,
double_check_batch_target_tokens: None,
auto_correct: false,
correction_rounds: 1,
fallback_provider: None,
fallback_model: None,
fallback_base_url: None,
fallback_api_key_env: None,
fallback_only: FallbackScope::Failed,
no_thinking: false,
model_context_tokens: None,
max_output_tokens: None,
batch_max_output_tokens: None,
json_mode: bookforge_core::JsonMode::Auto,
ui: crate::progress::UiMode::Quiet,
progress_jsonl: None,
provider_preset,
}
}
#[test]
fn explicit_cli_concurrency_overrides_provider_preset() {
let mut args =
translate_args_with_preset(Some(bookforge_core::ProviderPreset::OpenRouterPaidFast));
args.concurrency = Some(8);
let settings = resolve_settings(&args);
assert_eq!(settings.scheduler.concurrency, 8);
}
#[test]
fn explicit_cli_provider_max_attempts_overrides_provider_preset() {
let mut args =
translate_args_with_preset(Some(bookforge_core::ProviderPreset::OpenRouterPaidFast));
args.provider_max_attempts = Some(3);
let settings = resolve_settings(&args);
assert_eq!(settings.provider.provider_max_attempts, 3);
}
#[test]
fn provider_preset_runtime_is_reflected_in_resolved_settings() {
let args = translate_args_with_preset(Some(bookforge_core::ProviderPreset::OpenRouterFree));
let settings = resolve_settings(&args);
assert_eq!(settings.scheduler.concurrency, 2);
assert_eq!(
settings.provider.retry_after_policy,
bookforge_core::RetryAfterPolicy::RespectHeader
);
assert_eq!(settings.provider.max_idle_per_host, 8);
}
}