initial commit: OpenRouter free model radar Discord bot (WIP)

This commit is contained in:
katR 2026-09-17 07:51:54 +05:30
commit ee78de7923
7 changed files with 3091 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2476
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "model-radar"
version = "0.1.0"
edition = "2024"
[dependencies]
poise = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "signal", "time", "sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.dev]
debug = 0
[profile.test]
debug = 0

16
README.md Normal file
View file

@ -0,0 +1,16 @@
# model-radar
> **Status:** Work in Progress (WIP)
A Discord bot that monitors [OpenRouter](https://openrouter.ai) for free LLM models and alerts/pings users when new free models become available.
## Features (WIP)
- Polls OpenRouter model catalog API for free tiers (`:free`)
- Tracks pricing and availability diffs
- Discord slash commands via poise & serenity
- Automated role/user pings on model drops
## License
MIT

318
src/catalog.rs Normal file
View file

@ -0,0 +1,318 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::Error;
const ENDPOINT: &str = "https://openrouter.ai/api/v1/models";
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Model {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub context_length: u64,
#[serde(default)]
pub pricing: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Event {
pub model: Model,
pub kind: String,
}
fn is_decimal_zero(raw: &str) -> bool {
let body = raw.strip_prefix(['+', '-']).unwrap_or(raw);
let mantissa = if let Some((mantissa, exponent)) = body.split_once(['e', 'E']) {
let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
if exponent.is_empty() || !exponent.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
mantissa
} else {
body
};
let mut seen_dot = false;
let mut digits = 0usize;
mantissa.bytes().all(|b| match b {
b'.' if !seen_dot => {
seen_dot = true;
true
}
b'0' => {
digits += 1;
true
}
_ => false,
}) && digits > 0
}
impl Model {
pub fn is_free(&self) -> bool {
if !is_decimal_zero(
self.pricing
.get("prompt")
.map(String::as_str)
.unwrap_or_default(),
) || !is_decimal_zero(
self.pricing
.get("completion")
.map(String::as_str)
.unwrap_or_default(),
) {
return false;
}
self.pricing.values().all(|value| is_decimal_zero(value))
}
}
fn validate(catalog: &BTreeMap<String, Model>) -> Result<(), Error> {
if catalog.is_empty() {
return Err("catalog is empty".into());
}
for (key, model) in catalog {
if model.id.trim().is_empty() || model.name.trim().is_empty() {
return Err("model identity is empty".into());
}
if key != &model.id {
return Err(
format!("catalog key {key:?} does not match model id {:?}", model.id).into(),
);
}
}
Ok(())
}
fn parse_catalog(bytes: &[u8]) -> Result<BTreeMap<String, Model>, Error> {
#[derive(Deserialize)]
struct Response {
data: Vec<Model>,
}
let response: Response = serde_json::from_slice(bytes)?;
let mut catalog = BTreeMap::new();
for model in response.data {
if model.id.trim().is_empty() || model.name.trim().is_empty() {
return Err("model identity is empty".into());
}
if catalog.insert(model.id.clone(), model).is_some() {
return Err("duplicate model id".into());
}
}
validate(&catalog)?;
Ok(catalog)
}
pub async fn fetch(client: &reqwest::Client) -> Result<BTreeMap<String, Model>, Error> {
let mut response = client.get(ENDPOINT).send().await?.error_for_status()?;
let mut payload: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await? {
if chunk.len() > MAX_RESPONSE_BYTES - payload.len() {
return Err("response exceeds 8 MiB limit".into());
}
payload.extend_from_slice(&chunk);
}
parse_catalog(&payload)
}
pub fn changes(
previous: &BTreeMap<String, Model>,
current: &BTreeMap<String, Model>,
) -> Vec<Event> {
let mut events = Vec::new();
for (id, model) in current {
match previous.get(id) {
None => events.push(Event {
model: model.clone(),
kind: "new".to_string(),
}),
Some(old) if !old.is_free() && model.is_free() => events.push(Event {
model: model.clone(),
kind: "paid_to_free".to_string(),
}),
_ => {}
}
}
events
}
#[cfg(test)]
mod tests {
use super::*;
fn model(id: &str, pricing: &[(&str, &str)]) -> Model {
Model {
id: id.to_string(),
name: id.to_string(),
description: String::new(),
context_length: 4096,
pricing: pricing
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
fn map(models: Vec<Model>) -> BTreeMap<String, Model> {
models.into_iter().map(|m| (m.id.clone(), m)).collect()
}
#[test]
fn free_requires_exact_decimal_zero() {
assert!(model("a", &[("prompt", "0"), ("completion", "0")]).is_free());
assert!(model("a", &[("prompt", "0.0"), ("completion", "-0")]).is_free());
assert!(model("a", &[("prompt", "+0.00000"), ("completion", "0")]).is_free());
assert!(
!model(
"a",
&[("prompt", "0.00000000000000000001"), ("completion", "0")]
)
.is_free()
);
assert!(!model("a", &[("prompt", "1e-9"), ("completion", "0")]).is_free());
assert!(!model("a", &[("prompt", ""), ("completion", "0")]).is_free());
assert!(!model("a", &[("prompt", "abc"), ("completion", "0")]).is_free());
assert!(
!model(
"a",
&[("prompt", "0"), ("completion", "0"), ("request", "0.1")]
)
.is_free()
);
assert!(!model("a", &[]).is_free());
}
#[test]
fn decimal_zero_edge_cases() {
for value in ["0", "00", "-0", "+0.00", "0e-9999", "0E+9999", ".0", "0."] {
assert!(
model("a", &[("prompt", value), ("completion", value)]).is_free(),
"{value}"
);
}
for value in [
"1e-9999", "-1e-9999", "NaN", "inf", "-1", ".", "+", "-", "", "0e", "0e+", "0e1.0",
"0e0e0", "0..0", "--0", "0x0", " 0", "0 ", "0",
] {
assert!(
!model("a", &[("prompt", value), ("completion", "0")]).is_free(),
"{value}"
);
assert!(
!model("a", &[("prompt", "0"), ("completion", value)]).is_free(),
"{value}"
);
assert!(
!model(
"a",
&[("prompt", "0"), ("completion", "0"), ("unknown", value)]
)
.is_free(),
"{value}"
);
}
let tiny = format!("0.{}1", "0".repeat(400));
assert!(!model("a", &[("prompt", &tiny), ("completion", "0")]).is_free());
assert!(!model("a", &[("prompt", "0")]).is_free());
assert!(!model("a", &[("completion", "0")]).is_free());
assert!(
model(
"a",
&[("prompt", "0"), ("completion", "0"), ("unknown", "0.0")]
)
.is_free()
);
}
#[test]
fn changes_detects_new_and_paid_to_free() {
let prev = map(vec![
model("a", &[("prompt", "1"), ("completion", "1")]),
model("b", &[("prompt", "0"), ("completion", "0")]),
]);
let cur = map(vec![
model("a", &[("prompt", "0"), ("completion", "0")]),
model("b", &[("prompt", "0"), ("completion", "0")]),
model("c", &[("prompt", "0"), ("completion", "0")]),
model("d", &[("prompt", "2"), ("completion", "3")]),
]);
let events = changes(&prev, &cur);
let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
assert_eq!(kinds, vec!["paid_to_free", "new", "new"]);
assert_eq!(events[0].model.id, "a");
assert_eq!(events[1].model.id, "c");
assert_eq!(events[2].model.id, "d");
assert!(!events[2].model.is_free());
}
#[test]
fn changes_ignores_removals_and_unchanged() {
let prev = map(vec![model("a", &[("prompt", "0"), ("completion", "0")])]);
let cur = BTreeMap::new();
assert!(changes(&prev, &cur).is_empty());
assert_eq!(changes(&cur, &prev).len(), 1);
assert!(changes(&prev, &prev).is_empty());
let paid = map(vec![model("a", &[("prompt", "1"), ("completion", "1")])]);
assert!(changes(&prev, &paid).is_empty());
assert!(changes(&paid, &paid).is_empty());
}
#[test]
fn parse_rejects_empty() {
assert!(parse_catalog(b"{\"data\":[]}").is_err());
assert!(parse_catalog(b"{}").is_err());
}
#[test]
fn parse_rejects_malformed() {
for body in [
"not json",
r#"{"data":null}"#,
r#"{"data":{}}"#,
r#"{"data":[{"id":"","name":"A"}]}"#,
r#"{"data":[{"id":" ","name":"A"}]}"#,
r#"{"data":[{"id":"a","name":" "}]}"#,
r#"{"data":[{"name":"A"}]}"#,
r#"{"data":[{"id":"a"}]}"#,
r#"{"data":[{"id":1,"name":"A"}]}"#,
r#"{"data":[{"id":"a","name":"A","context_length":-1}]}"#,
r#"{"data":[{"id":"a","name":"A","pricing":{"prompt":0}}]}"#,
r#"{"data":[{"id":"a","name":"A","description":null}]}"#,
r#"{"data":[{"id":"a","name":"A"}]} trailing"#,
] {
assert!(parse_catalog(body.as_bytes()).is_err(), "{body}");
}
}
#[test]
fn parse_rejects_duplicates() {
let body = r#"{"data":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}"#;
assert!(parse_catalog(body.as_bytes()).is_err());
}
#[test]
fn parse_accepts_valid() {
let body = r#"{"data":[{"id":"a","name":"A","context_length":8192,"pricing":{"prompt":"0","completion":"0"}}]}"#;
let catalog = parse_catalog(body.as_bytes()).unwrap();
assert_eq!(catalog.len(), 1);
assert!(catalog["a"].is_free());
assert_eq!(catalog["a"].context_length, 8192);
}
#[test]
fn parse_defaults_optional_metadata_and_prices() {
let catalog = parse_catalog(br#"{"data":[{"id":"a","name":"A"}]}"#).unwrap();
assert_eq!(catalog["a"].description, "");
assert_eq!(catalog["a"].context_length, 0);
assert!(catalog["a"].pricing.is_empty());
assert!(!catalog["a"].is_free());
}
#[test]
fn validate_rejects_empty_catalog() {
assert!(validate(&BTreeMap::new()).is_err());
}
}

38
src/main.rs Normal file
View file

@ -0,0 +1,38 @@
mod catalog;
mod store;
use poise::serenity_prelude as serenity;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, (), Error>;
#[poise::command(slash_command)]
async fn ping(ctx: Context<'_>) -> Result<(), Error> {
ctx.say("Model Radar is online.").await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let token = std::env::var("DISCORD_TOKEN")?;
let mut ping = ping();
ping.description = Some("Check whether Model Radar is online".into());
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: vec![ping],
..Default::default()
})
.setup(|ctx, _, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(())
})
})
.build();
serenity::ClientBuilder::new(token, serenity::GatewayIntents::GUILDS)
.framework(framework)
.await?
.start()
.await?;
Ok(())
}

223
src/store.rs Normal file
View file

@ -0,0 +1,223 @@
use std::collections::BTreeMap;
use std::io::{ErrorKind, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use crate::Error;
use crate::catalog::{Event, Model};
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
pub struct State {
pub initialized: bool,
pub catalog: BTreeMap<String, Model>,
pub subscriptions: BTreeMap<u64, Subscription>,
pub last_success: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Subscription {
pub channel_id: u64,
pub role_id: Option<u64>,
pub free_only: bool,
pub pending: Vec<Event>,
}
pub struct Store {
pub state: State,
path: PathBuf,
}
impl Store {
pub async fn load(path: PathBuf) -> Result<Self, Error> {
let state = match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes)?,
Err(error) if error.kind() == ErrorKind::NotFound => State::default(),
Err(error) => return Err(error.into()),
};
Ok(Self { state, path })
}
pub async fn commit(&mut self, next: State) -> Result<(), Error> {
let bytes = serde_json::to_vec(&next)?;
let path = self.path.clone();
tokio::task::spawn_blocking(move || -> Result<(), Error> {
let name = path.file_name().ok_or("state path has no file name")?;
let (temporary, mut file) = loop {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let mut temporary_name = name.to_os_string();
temporary_name.push(format!(".{}.{}.tmp", std::process::id(), sequence));
let temporary = path.with_file_name(temporary_name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
{
Ok(file) => break (temporary, file),
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
};
let result = (|| -> Result<(), Error> {
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
std::fs::rename(&temporary, &path)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
})
.await??;
self.state = next;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
loop {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"model-radar-store-test-{}-{}",
std::process::id(),
sequence
));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => panic!("{error}"),
}
}
}
fn state_path(&self) -> PathBuf {
self.0.join("state.json")
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn populated_state() -> State {
let model = Model {
id: "example/model".into(),
name: "Example".into(),
description: "Description".into(),
context_length: 8192,
pricing: BTreeMap::from([
("prompt".into(), "0".into()),
("completion".into(), "0".into()),
]),
};
State {
initialized: true,
catalog: BTreeMap::from([(model.id.clone(), model.clone())]),
subscriptions: BTreeMap::from([
(
u64::MAX,
Subscription {
channel_id: 42,
role_id: Some(99),
free_only: true,
pending: vec![Event {
model,
kind: "new".into(),
}],
},
),
(
1,
Subscription {
channel_id: 43,
role_id: None,
free_only: false,
pending: Vec::new(),
},
),
]),
last_success: Some(123456),
}
}
#[tokio::test]
async fn missing_file_defaults_without_writing() {
let directory = TestDirectory::new();
let path = directory.state_path();
let store = Store::load(path.clone()).await.unwrap();
assert!(!store.state.initialized);
assert!(store.state.catalog.is_empty());
assert!(store.state.subscriptions.is_empty());
assert_eq!(store.state.last_success, None);
assert!(!path.exists());
}
#[tokio::test]
async fn persistence_round_trip_and_replacement() {
let directory = TestDirectory::new();
let path = directory.state_path();
let mut store = Store::load(path.clone()).await.unwrap();
let next = populated_state();
store.commit(next.clone()).await.unwrap();
let loaded = Store::load(path.clone()).await.unwrap();
let expected = serde_json::to_value(&next).unwrap();
assert_eq!(serde_json::to_value(&store.state).unwrap(), expected);
assert_eq!(serde_json::to_value(&loaded.state).unwrap(), expected);
store.commit(State::default()).await.unwrap();
let loaded = Store::load(path).await.unwrap();
assert!(!loaded.state.initialized);
assert!(loaded.state.catalog.is_empty());
assert!(loaded.state.subscriptions.is_empty());
assert_eq!(loaded.state.last_success, None);
assert_eq!(std::fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[tokio::test]
async fn corrupt_files_are_errors_and_not_overwritten() {
let directory = TestDirectory::new();
let path = directory.state_path();
for bytes in ["", "not json", "{}", "null", "{\"initialized\":false"] {
tokio::fs::write(&path, bytes).await.unwrap();
assert!(Store::load(path.clone()).await.is_err());
assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes.as_bytes());
}
}
#[tokio::test]
async fn failed_rename_preserves_state_and_cleans_temporary() {
let directory = TestDirectory::new();
let path = directory.state_path();
let mut store = Store::load(path.clone()).await.unwrap();
tokio::fs::create_dir(&path).await.unwrap();
assert!(store.commit(populated_state()).await.is_err());
assert!(!store.state.initialized);
assert!(store.state.catalog.is_empty());
assert!(path.is_dir());
assert_eq!(std::fs::read_dir(&directory.0).unwrap().count(), 1);
assert!(Store::load(path).await.is_err());
}
#[tokio::test]
async fn missing_parent_commit_preserves_state() {
let directory = TestDirectory::new();
let path = directory.0.join("missing").join("state.json");
let mut store = Store::load(path).await.unwrap();
assert!(store.commit(populated_state()).await.is_err());
assert!(!store.state.initialized);
assert!(store.state.subscriptions.is_empty());
}
}