fix(modelwatch,fetch): param-count fallback + browser-shaped feed headers
All checks were successful
deploy / build-web (push) Successful in 1m38s
deploy / deploy-web (push) Successful in 3s
deploy / build-api (push) Successful in 6m51s
deploy / deploy-api (push) Successful in 12s

Two fixes surfaced by the live feed.

modelwatch: some repos report a bogus tiny `safetensors.total` (e.g.
deepreinforce-ai/Ornith-1.0-35B came through as "1M params"), which both
misrendered and — worse — let an oversized model slip past the size cap. Add
`params_from_name` (parse the first <num>B|M token from the repo id, which by
convention is the total size) and `effective_params` (trust safetensors.total
only when it's >= 100M, else fall back to the name). Use it for the size filter
and the summary. New unit tests cover the name parser, the fallback, and that a
70B reporting a 1M total is still rejected as too large. (Forward-looking: the
idempotent ingest won't re-render items already in the feed.)

fetch: feed requests now send browser-shaped `Accept` / `Accept-Language`
headers. reqwest sends no Accept by default, which some anti-bot filters 403.
NB this is hygiene, not a Reddit fix — Reddit fingerprints the TLS/HTTP client
(rustls) and hard-403s it where curl only gets rate-limited (429); headers don't
change the fingerprint. The control path (github releases.atom, etc.) is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
2026-07-08 15:25:36 +03:00
parent 6555ed2fed
commit 243b17e9b3
4 changed files with 104 additions and 11 deletions

1
Cargo.lock generated
View File

@@ -1380,6 +1380,7 @@ dependencies = [
"clap", "clap",
"figment", "figment",
"newsfeed-entities", "newsfeed-entities",
"regex",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -31,6 +31,18 @@ const DEFAULT_UA: &str = concat!(
" (feed discovery)" " (feed discovery)"
); );
/// Browser-like `Accept` advertising a feed preference. Sending no `Accept` (reqwest's
/// default) trips some anti-bot filters — notably Reddit, which hard-403s a header-less
/// client while only rate-limiting a browser-shaped one.
const FEED_ACCEPT: &str =
"application/rss+xml, application/atom+xml, application/xml;q=0.9, text/html;q=0.8, */*;q=0.7";
/// Attach the browser-shaped headers every feed request should carry.
fn feed_headers(rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
rb.header(reqwest::header::ACCEPT, FEED_ACCEPT)
.header(reqwest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9")
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Worker path: fetch a known feed URL and parse it into backend-agnostic entries. // Worker path: fetch a known feed URL and parse it into backend-agnostic entries.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -43,8 +55,7 @@ pub async fn fetch_entries(client: &Client, url: &str) -> anyhow::Result<Vec<Fee
} }
async fn get_bytes(client: &Client, url: &str) -> anyhow::Result<Vec<u8>> { async fn get_bytes(client: &Client, url: &str) -> anyhow::Result<Vec<u8>> {
let bytes = client let bytes = feed_headers(client.get(url))
.get(url)
.send() .send()
.await .await
.with_context(|| format!("fetching {url}"))? .with_context(|| format!("fetching {url}"))?
@@ -170,9 +181,7 @@ impl HttpFeedProber {
/// Fetch a URL, returning the final URL (after redirects) and the body bytes. /// Fetch a URL, returning the final URL (after redirects) and the body bytes.
async fn fetch(&self, url: &str) -> anyhow::Result<(Url, Vec<u8>)> { async fn fetch(&self, url: &str) -> anyhow::Result<(Url, Vec<u8>)> {
let resp = self let resp = feed_headers(self.client.get(url))
.client
.get(url)
.send() .send()
.await .await
.with_context(|| format!("fetching {url}"))? .with_context(|| format!("fetching {url}"))?

View File

@@ -24,6 +24,7 @@ reqwest.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
chrono.workspace = true chrono.workspace = true
regex.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true

View File

@@ -9,12 +9,18 @@
//! weights bite. See the newsfeed CLAUDE.md house rule. //! weights bite. See the newsfeed CLAUDE.md house rule.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use newsfeed_entities::item::CandidateSubmission; use newsfeed_entities::item::CandidateSubmission;
/// Below this, a `safetensors.total` is treated as bogus (some repos report a tiny/partial
/// total) and we fall back to the parameter count in the repo name.
const IMPLAUSIBLE_PARAMS_FLOOR: u64 = 100_000_000;
/// A model record from the HF Hub API. The list and detail endpoints share this shape; /// A model record from the HF Hub API. The list and detail endpoints share this shape;
/// detail-only fields (`safetensors`, `card_data`) are optional so one struct serves both. /// detail-only fields (`safetensors`, `card_data`) are optional so one struct serves both.
/// Unknown fields are ignored, so the API can grow without breaking us. /// Unknown fields are ignored, so the API can grow without breaking us.
@@ -95,6 +101,34 @@ pub fn license(m: &HfModel) -> Option<String> {
.find_map(|t| t.strip_prefix("license:").map(|s| s.to_lowercase())) .find_map(|t| t.strip_prefix("license:").map(|s| s.to_lowercase()))
} }
/// Parse a parameter count from a repo name, e.g. `Qwen3-235B-A22B` → 235e9,
/// `granite-3b-a600m` → 3e9, `SmolLM-360M` → 360e6. Takes the first `<num>B|M` token, which
/// by convention is the model's total size (the second, for MoE, is the active count).
pub fn params_from_name(id: &str) -> Option<u64> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)\s*([bm])\b").unwrap());
let name = id.rsplit('/').next().unwrap_or(id);
let caps = re.captures(name)?;
let n: f64 = caps.get(1)?.as_str().parse().ok()?;
let mult = if caps.get(2)?.as_str().eq_ignore_ascii_case("b") {
1e9
} else {
1e6
};
Some((n * mult) as u64)
}
/// Best available parameter count: trust `safetensors.total` when it's plausible, else fall
/// back to the size in the repo name (some repos report a bogus tiny total, which would
/// both misrender and let an oversized model slip past the size filter).
pub fn effective_params(m: &HfModel) -> Option<u64> {
let total = m.safetensors.as_ref().map(|s| s.total).filter(|&t| t > 0);
match total {
Some(t) if t >= IMPLAUSIBLE_PARAMS_FLOOR => Some(t),
_ => params_from_name(&m.id).or(total),
}
}
/// The admission predicate: which releases are worth submitting. Configurable; an empty /// The admission predicate: which releases are worth submitting. Configurable; an empty
/// allowlist means "don't filter on this axis". /// allowlist means "don't filter on this axis".
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -158,12 +192,15 @@ impl Predicate {
{ {
return Err("pipeline_tag not allowed"); return Err("pipeline_tag not allowed");
} }
match &m.safetensors { if self.require_safetensors && m.safetensors.is_none() {
None if self.require_safetensors => return Err("no safetensors"), return Err("no safetensors");
Some(st) if self.max_params > 0 && st.total > self.max_params => { }
return Err("too large"); if self.max_params > 0 {
if let Some(p) = effective_params(m) {
if p > self.max_params {
return Err("too large");
}
} }
_ => {}
} }
if !self.licenses.is_empty() if !self.licenses.is_empty()
&& !license(m).is_some_and(|l| self.licenses.iter().any(|a| a == &l)) && !license(m).is_some_and(|l| self.licenses.iter().any(|a| a == &l))
@@ -186,7 +223,7 @@ pub fn to_submission(m: &HfModel, source: &str) -> CandidateSubmission {
.or_else(|| m.id.split('/').next().map(str::to_string)); .or_else(|| m.id.split('/').next().map(str::to_string));
let mut parts = Vec::new(); let mut parts = Vec::new();
if let Some(total) = m.safetensors.as_ref().map(|s| s.total).filter(|&t| t > 0) { if let Some(total) = effective_params(m) {
parts.push(human_params(total)); parts.push(human_params(total));
} }
if let Some(l) = license(m) { if let Some(l) = license(m) {
@@ -348,4 +385,49 @@ mod tests {
assert_eq!(human_params(14_000_000_000), "14.0B params"); assert_eq!(human_params(14_000_000_000), "14.0B params");
assert_eq!(human_params(770_000_000), "770M params"); assert_eq!(human_params(770_000_000), "770M params");
} }
#[test]
fn params_from_name_takes_first_size_token() {
assert_eq!(
params_from_name("Qwen/Qwen3-235B-A22B"),
Some(235_000_000_000)
);
assert_eq!(
params_from_name("ibm-granite/granite-3b-a600m"),
Some(3_000_000_000)
);
assert_eq!(params_from_name("x/Ornith-1.0-35B"), Some(35_000_000_000));
assert_eq!(params_from_name("HF/SmolLM-360M"), Some(360_000_000));
assert_eq!(
params_from_name("nvidia/Qwen3.6-27B-NVFP4"),
Some(27_000_000_000)
);
assert_eq!(params_from_name("openai/gpt-oss"), None); // no size token
}
#[test]
fn effective_params_falls_back_when_total_is_bogus() {
// Bogus tiny total (the real Ornith case) → use the name's size.
let m =
model(r#"{"id":"deepreinforce-ai/Ornith-1.0-35B","safetensors":{"total":1000000}}"#);
assert_eq!(effective_params(&m), Some(35_000_000_000));
// Plausible total → trust it.
let m = model(r#"{"id":"a/b-7B","safetensors":{"total":7000000000}}"#);
assert_eq!(effective_params(&m), Some(7_000_000_000));
// No safetensors block → still recover a size from the name.
assert_eq!(
effective_params(&model(r#"{"id":"a/Foo-9B"}"#)),
Some(9_000_000_000)
);
}
#[test]
fn bogus_total_does_not_let_an_oversized_model_through() {
// 70B model reporting a 1M total must still be rejected by the size cap.
let m = model(
r#"{"id":"x/Whale-70B","pipeline_tag":"text-generation",
"cardData":{"license":"mit"},"safetensors":{"total":1000000}}"#,
);
assert_eq!(Predicate::default().admit(&m), Err("too large"));
}
} }