fix(modelwatch,fetch): param-count fallback + browser-shaped feed headers
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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1380,6 +1380,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"figment",
|
"figment",
|
||||||
"newsfeed-entities",
|
"newsfeed-entities",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -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}"))?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user