feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé

Ajoute un détecteur niveau 2 (RateLimitParser, regex confiné à l'infra) qui
repère les mentions de limite de session dans la sortie textuelle, et factorise
le parsing d'heures dans un module pur (timeparse) partagé entre les niveaux 1
et 2. Le détecteur Claude niveau 1 est refactoré vers timeparse (~-121 lignes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 20:02:24 +02:00
parent 9000b4d09f
commit 98bfcf4f22
7 changed files with 724 additions and 122 deletions

View File

@ -190,131 +190,28 @@ pub fn parse_reset_ms(rate_limit_info: &Value) -> Option<i64> {
value_to_epoch_ms(raw)
}
/// Seuil de magnitude départageant un epoch en **secondes** d'un epoch en
/// **millisecondes** : `10^12 ms ≈ 2001-09-09`, `10^12 s ≈ an 33658`. Tout epoch
/// plausible (≥ 1970) de magnitude `≥ 10^12` est donc déjà des millisecondes ;
/// en-dessous, ce sont des secondes.
const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000;
/// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement.
///
/// La conversion **générique** (heuristique secondes-vs-ms, parseur ISO-8601, algo
/// jour-civil) est factorisée dans [`crate::timeparse`] et **partagée** avec le
/// détecteur de niveau 2 (`ratelimit`) — pas de duplication (§21.2-T2). Seule
/// l'extraction depuis un `serde_json::Value` (typage int/float/str) reste ici.
fn value_to_epoch_ms(v: &Value) -> Option<i64> {
if let Some(i) = v.as_i64() {
return Some(int_epoch_to_ms(i));
return Some(crate::timeparse::int_epoch_to_ms(i));
}
if let Some(u) = v.as_u64() {
return Some(int_epoch_to_ms(i64::try_from(u).ok()?));
return Some(crate::timeparse::int_epoch_to_ms(i64::try_from(u).ok()?));
}
if let Some(f) = v.as_f64() {
return Some(float_epoch_to_ms(f));
return Some(crate::timeparse::float_epoch_to_ms(f));
}
if let Some(s) = v.as_str() {
let t = s.trim();
if let Ok(i) = t.parse::<i64>() {
return Some(int_epoch_to_ms(i));
}
if let Ok(f) = t.parse::<f64>() {
return Some(float_epoch_to_ms(f));
}
return parse_rfc3339_to_ms(t);
return crate::timeparse::parse_absolute_ms(s);
}
None
}
/// Applique l'heuristique secondes-vs-ms à un epoch **entier**.
const fn int_epoch_to_ms(n: i64) -> i64 {
if n.abs() >= EPOCH_MS_THRESHOLD {
n
} else {
n * 1000
}
}
/// Idem pour un epoch **flottant** (fraction de seconde préservée → ms).
fn float_epoch_to_ms(f: f64) -> i64 {
if f.abs() >= EPOCH_MS_THRESHOLD as f64 {
f as i64
} else {
(f * 1000.0) as i64
}
}
/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en
/// époche-ms. Pur, dépendance-zéro (aucune crate de date). Best-effort : retourne
/// `None` sur toute forme non reconnue. Sans désignateur de fuseau ⇒ traité **UTC**.
fn parse_rfc3339_to_ms(s: &str) -> Option<i64> {
let (date, rest) = s.split_once(['T', 't', ' '])?;
let mut dp = date.split('-');
let year: i64 = dp.next()?.parse().ok()?;
let month: i64 = dp.next()?.parse().ok()?;
let day: i64 = dp.next()?.parse().ok()?;
if dp.next().is_some() {
return None;
}
let (time, tz_offset_secs) = split_tz(rest)?;
let mut tp = time.split(':');
let hour: i64 = tp.next()?.parse().ok()?;
let minute: i64 = tp.next()?.parse().ok()?;
let (second, millis) = split_seconds_frac(tp.next().unwrap_or("0"))?;
if tp.next().is_some() {
return None;
}
let days = days_from_civil(year, month, day);
let epoch_secs = days * 86_400 + hour * 3_600 + minute * 60 + second - tz_offset_secs;
Some(epoch_secs * 1000 + millis)
}
/// Sépare la partie heure de son **désignateur de fuseau** et renvoie l'offset en
/// secondes (à **soustraire** de l'heure locale pour obtenir l'UTC). `Z`/`z` ⇒ 0 ;
/// `±hh:mm` ou `±hhmm` ⇒ offset signé ; aucun désignateur ⇒ 0 (UTC best-effort).
fn split_tz(rest: &str) -> Option<(&str, i64)> {
if let Some(stripped) = rest.strip_suffix(['Z', 'z']) {
return Some((stripped, 0));
}
if let Some(pos) = rest.rfind(['+', '-']) {
let (time, tz) = rest.split_at(pos);
let sign = if tz.starts_with('-') { -1 } else { 1 };
let tz = &tz[1..];
let (h, m) = if let Some((h, m)) = tz.split_once(':') {
(h.parse::<i64>().ok()?, m.parse::<i64>().ok()?)
} else if tz.len() == 4 {
(tz[0..2].parse::<i64>().ok()?, tz[2..4].parse::<i64>().ok()?)
} else {
(tz.parse::<i64>().ok()?, 0)
};
return Some((time, sign * (h * 3_600 + m * 60)));
}
Some((rest, 0))
}
/// Sépare `SS` ou `SS.fff…` en `(secondes, millisecondes)`. La fraction est tronquée
/// /complétée à **3 chiffres** (précision ms).
fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
let Some((sec, frac)) = s.split_once('.') else {
return Some((s.parse::<i64>().ok()?, 0));
};
let sec = sec.parse::<i64>().ok()?;
let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect();
while d3.len() < 3 {
d3.push('0');
}
let millis = d3.parse::<i64>().ok()?;
Some((sec, millis))
}
/// Jours depuis l'époque Unix (1970-01-01) pour une date civile proleptique
/// grégorienne. Algorithme de Howard Hinnant (`days_from_civil`), exact et
/// dépendance-zéro ; gère bissextiles et siècles.
const fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// Adapter de session structurée Claude.
///
/// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la