#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod config;
mod judge;
mod openrouter;
mod precheck;
mod presets;
mod types;
pub use config::Config;
pub use openrouter::DEFAULT_BASE_URL;
pub use precheck::normalize;
pub use presets::Preset;
pub use types::{Comment, Decision, Level, Reason, Verdict};
use precheck::Prechecks;
use std::time::Instant;
pub const DEFAULT_MODEL: &str = "~typesafe/jev-latest";
const BATCH_CONCURRENCY: usize = 8;
pub const API_KEY_ENV: &str = "OPENROUTER_API_KEY";
#[derive(Debug, Clone, PartialEq)]
pub struct Options {
pub level: Level,
pub preset: Preset,
pub rules: Vec<String>,
pub stock_phrases: Vec<String>,
pub models: Vec<String>,
}
impl Default for Options {
fn default() -> Self {
Options {
level: Level::default(),
preset: Preset::default(),
rules: Vec::new(),
stock_phrases: Vec::new(),
models: vec![DEFAULT_MODEL.to_string()],
}
}
}
#[derive(Debug)]
pub enum Error {
MissingApiKey,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::MissingApiKey => write!(
f,
"{API_KEY_ENV} is not set; get a key at https://openrouter.ai/keys and export it"
),
}
}
}
impl std::error::Error for Error {}
fn normalize_all(texts: &[String]) -> Vec<String> {
texts.iter().map(|t| normalize(t)).collect()
}
#[derive(Debug, Clone)]
pub struct Checker {
options: Options,
prechecks: Prechecks,
client: Option<openrouter::Client>,
questions: serde_json::Value,
}
impl Checker {
pub fn new(options: Options, api_key: Option<String>) -> Self {
Self::with_base_url(options, api_key, DEFAULT_BASE_URL)
}
pub fn with_base_url(options: Options, api_key: Option<String>, base_url: &str) -> Self {
Checker {
prechecks: Prechecks::new(Self::extra_stock(&options)),
client: api_key
.filter(|k| !k.trim().is_empty())
.map(|k| openrouter::Client::new(base_url, k)),
questions: judge::questions(options.preset, &options.rules),
options,
}
}
pub fn at_level(mut self, level: Level) -> Self {
self.options.level = level;
self
}
pub fn with_options(&self, options: Options) -> Self {
Checker {
prechecks: Prechecks::new(Self::extra_stock(&options)),
questions: judge::questions(options.preset, &options.rules),
client: self.client.clone(),
options,
}
}
fn extra_stock(options: &Options) -> Vec<&str> {
options
.preset
.stock_phrases()
.iter()
.copied()
.chain(options.stock_phrases.iter().map(String::as_str))
.collect()
}
pub fn options(&self) -> &Options {
&self.options
}
pub fn precheck(&self, comment: &Comment, recent: &[String]) -> Option<Verdict> {
self.precheck_normalized(comment, &normalize_all(recent))
}
fn precheck_normalized(&self, comment: &Comment, recent: &[String]) -> Option<Verdict> {
let start = Instant::now();
let reason = self
.prechecks
.run(&comment.text, self.options.level, recent)?;
Some(Verdict {
verdict: Decision::Reject,
reason,
confidence: 1.0,
level: self.options.level,
model: None,
latency_ms: start.elapsed().as_millis() as u64,
rule: None,
})
}
pub async fn check_batch(
&self,
comments: &[Comment],
recent: &[String],
) -> Result<Vec<Verdict>, Error> {
use futures_util::{StreamExt, stream};
let recent = normalize_all(recent);
let checks: Vec<_> = comments
.iter()
.map(|c| self.check_normalized(c, &recent))
.collect();
stream::iter(checks)
.buffered(BATCH_CONCURRENCY)
.collect::<Vec<_>>()
.await
.into_iter()
.collect()
}
pub async fn check(&self, comment: &Comment, recent: &[String]) -> Result<Verdict, Error> {
self.check_normalized(comment, &normalize_all(recent)).await
}
async fn check_normalized(
&self,
comment: &Comment,
recent: &[String],
) -> Result<Verdict, Error> {
let start = Instant::now();
if let Some(v) = self.precheck_normalized(comment, recent) {
return Ok(v);
}
let client = self.client.as_ref().ok_or(Error::MissingApiKey)?;
let state = judge::state(comment, self.options.preset);
let level = self.options.level;
for model in &self.options.models {
let asked = Instant::now();
let answer = match client.decide(model, &state, &self.questions).await {
Ok(resp) => judge::judge(level, &self.options.rules, &resp.answers)
.map(|j| (resp.model, j))
.ok_or_else(|| "malformed answers".to_string()),
Err(e) => Err(e),
};
match answer {
Ok((answered_by, j)) => {
log::info!(
"model {model} answered in {} ms",
asked.elapsed().as_millis()
);
return Ok(Verdict {
verdict: j.decision,
reason: j.reason,
confidence: j.confidence,
level,
model: Some(answered_by),
latency_ms: start.elapsed().as_millis() as u64,
rule: j.rule,
});
}
Err(e) => log::warn!("model {model} failed ({e}); trying the next model"),
}
}
log::warn!("no model answered");
Ok(Verdict {
verdict: Decision::NeedsReview,
reason: Reason::ModelUnavailable,
confidence: 0.0,
level,
model: None,
latency_ms: start.elapsed().as_millis() as u64,
rule: None,
})
}
}