///|
/// Shared quota-verdict classification for OpenAI Chat Completions–style
/// providers.
///
/// Coding-plan providers answer quota exhaustion with a throttling verdict,
/// but both the HTTP status and the reset hint's carrier differ per provider:
/// most use 429, Kimi's coding plan answers 403 ("You've reached your 5-hour
/// usage limit"); OpenAI/Codex puts `resets_at` (epoch seconds) and
/// `resets_in_seconds` in the error JSON; z.ai/bigmodel puts the flush time
/// inside the human message text ("Your limit will reset at 2026-08-21
/// 15:04:16"); generic gateways expose only a `retry_after`. This module
/// unifies those shapes into the typed `@posoco.RateLimitInfo`, so a
/// ModelPort raises `ModelError::RateLimited` instead of string-matching
/// `Transport`.
///
/// Status alone cannot gate the verdict (a 403 is usually an auth failure),
/// so non-429 bodies classify only when their phrasing reads as a quota
/// verdict. Transient 429s (z.ai 1302 concurrency, 1305 overload) carry no
/// reset time; they still classify as RateLimited with `reset_at_ms = None`
/// and the caller decides whether to back off briefly instead of scheduling.
///|
/// Days from civil date (Howard Hinnant's algorithm); pure integer math so
/// timezone-free timestamps convert without a datetime dependency.
fn days_from_civil(y : Int, m : Int, d : Int) -> Int64 {
let yy = if m <= 2 { y - 1 } else { y }
let era = if yy >= 0 { yy / 400 } else { (yy - 399) / 400 }
let yoe = yy - era * 400
let mp = (m + 9) % 12
let doy = (153 * mp + 2) / 5 + d - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
era.to_int64() * 146097L + doe.to_int64() - 719468L
}
///|
fn civil_to_epoch_ms(
y : Int,
m : Int,
d : Int,
hh : Int,
mm : Int,
ss : Int,
tz_offset_minutes : Int,
) -> Int64? {
if m < 1 ||
m > 12 ||
d < 1 ||
d > 31 ||
hh < 0 ||
hh > 23 ||
mm < 0 ||
mm > 59 ||
ss < 0 ||
ss > 60 {
return None
}
let days = days_from_civil(y, m, d)
let secs = days * 86400L +
hh.to_int64() * 3600L +
mm.to_int64() * 60L +
ss.to_int64() -
tz_offset_minutes.to_int64() * 60L
Some(secs * 1000L)
}
///|
fn is_digit_code(code : UInt16) -> Bool {
code >= '0' && code <= '9'
}
///|
fn digit_value(code : UInt16) -> Int {
(code - '0').to_int()
}
///|
fn scan_digits(text : String, i : Int, count : Int) -> (Int, Int)? {
if i < 0 || i + count > text.length() {
return None
}
let mut value = 0
for k in 0.. (Int, Int)? {
let n = text.length()
let mut j = i
while j < n && text[j] == ' ' {
j += 1
}
let mut wrapped = false
if j < n && text[j] == '(' {
wrapped = true
j += 1
}
if j + 3 <= n {
let c0 = text[j]
let c1 = text[j + 1]
let c2 = text[j + 2]
if (c0 == 'U' || c0 == 'u') &&
(c1 == 'T' || c1 == 't') &&
(c2 == 'C' || c2 == 'c') {
j += 3
}
}
if j < n && text[j] == 'Z' && !wrapped {
return Some((0, j + 1))
}
if j >= n || (text[j] != '+' && text[j] != '-') {
return None
}
let sign = if text[j] == '+' { 1 } else { -1 }
j += 1
let (hh, after_hh) = match scan_digits(text, j, 2) {
Some((two, next2)) => (two, next2)
None =>
match scan_digits(text, j, 1) {
Some((one, next1)) => (one, next1)
None => return None
}
}
j = after_hh
let mut mm = 0
if j < n && text[j] == ':' {
match scan_digits(text, j + 1, 2) {
Some((value, next)) => {
mm = value
j = next
}
None => ()
}
} else if j + 2 <= n && is_digit_code(text[j]) && is_digit_code(text[j + 1]) {
match scan_digits(text, j, 2) {
Some((value, next)) => {
mm = value
j = next
}
None => ()
}
}
if wrapped && j < n && text[j] == ')' {
j += 1
}
if hh > 14 || mm > 59 {
return None
}
Some((sign * (hh * 60 + mm), j))
}
///|
fn match_month_prefix(text : String, i : Int) -> Int? {
if i + 3 > text.length() {
return None
}
fn lower(code : UInt16) -> UInt16 {
if code >= 'A' && code <= 'Z' {
code - 'A' + 'a'
} else {
code
}
}
let c0 = lower(text[i])
let c1 = lower(text[i + 1])
let c2 = lower(text[i + 2])
if c0 == 'j' && c1 == 'a' && c2 == 'n' {
return Some(1)
}
if c0 == 'f' && c1 == 'e' && c2 == 'b' {
return Some(2)
}
if c0 == 'm' && c1 == 'a' && c2 == 'r' {
return Some(3)
}
if c0 == 'a' && c1 == 'p' && c2 == 'r' {
return Some(4)
}
if c0 == 'm' && c1 == 'a' && c2 == 'y' {
return Some(5)
}
if c0 == 'j' && c1 == 'u' && c2 == 'n' {
return Some(6)
}
if c0 == 'j' && c1 == 'u' && c2 == 'l' {
return Some(7)
}
if c0 == 'a' && c1 == 'u' && c2 == 'g' {
return Some(8)
}
if c0 == 's' && c1 == 'e' && c2 == 'p' {
return Some(9)
}
if c0 == 'o' && c1 == 'c' && c2 == 't' {
return Some(10)
}
if c0 == 'n' && c1 == 'o' && c2 == 'v' {
return Some(11)
}
if c0 == 'd' && c1 == 'e' && c2 == 'c' {
return Some(12)
}
None
}
///|
/// `YYYY-MM-DD[T ]HH:MM:SS[.fff]` plus optional timezone suffix.
fn try_iso_at(text : String, i : Int, default_tz : Int) -> (Int64, Int)? {
let n = text.length()
if i + 19 > n {
return None
}
if text[i + 4] != '-' || text[i + 7] != '-' {
return None
}
let sep = text[i + 10]
if sep != 'T' && sep != 't' && sep != ' ' {
return None
}
if text[i + 13] != ':' || text[i + 16] != ':' {
return None
}
let (year, month, day, hh, mi, ss) = match
(
scan_digits(text, i, 4),
scan_digits(text, i + 5, 2),
scan_digits(text, i + 8, 2),
scan_digits(text, i + 11, 2),
scan_digits(text, i + 14, 2),
scan_digits(text, i + 17, 2),
) {
(
Some((y, _)),
Some((mo, _)),
Some((d, _)),
Some((h, _)),
Some((mi2, _)),
Some((s, _)),
) => (y, mo, d, h, mi2, s)
_ => return None
}
let mut k = i + 19
if k < n && text[k] == '.' {
let mut frac_end = k + 1
while frac_end < n && is_digit_code(text[frac_end]) {
frac_end += 1
}
if frac_end > k + 1 {
k = frac_end
}
}
let (tz, after_tz) = match scan_tz_offset(text, k) {
Some((offset, next)) => (offset, next)
None => (default_tz, k)
}
let epoch_ms = match civil_to_epoch_ms(year, month, day, hh, mi, ss, tz) {
Some(ms) => ms
None => return None
}
Some((epoch_ms, after_tz))
}
///|
/// `Aug 13, 2026 at 15:52` / `… at 3:45pm` (Codex message style).
fn parse_english_date_at(
text : String,
i : Int,
default_tz : Int,
) -> (Int64, Int)? {
let n = text.length()
let month = match match_month_prefix(text, i) {
Some(m) => m
None => return None
}
let j = i + 3
if j >= n || text[j] != ' ' {
return None
}
let (day, after_day) = match scan_digits(text, j + 1, 2) {
Some((two, next2)) => (two, next2)
None =>
match scan_digits(text, j + 1, 1) {
Some((one, next1)) => (one, next1)
None => return None
}
}
let mut k = after_day
if k >= n || text[k] != ',' {
return None
}
k += 1
if k >= n || text[k] != ' ' {
return None
}
let (year, after_year) = match scan_digits(text, k + 1, 4) {
Some(pair) => pair
None => return None
}
let mut m = after_year
while m < n && text[m] == ' ' {
m += 1
}
if m + 3 > n || text[m] != 'a' || text[m + 1] != 't' || text[m + 2] != ' ' {
return None
}
m += 3
while m < n && text[m] == ' ' {
m += 1
}
let (hour_raw, after_hour) = match scan_digits(text, m, 2) {
Some((two, next2)) => (two, next2)
None =>
match scan_digits(text, m, 1) {
Some((one, next1)) => (one, next1)
None => return None
}
}
let mut p = after_hour
if p >= n || text[p] != ':' {
return None
}
let (minute, after_minute) = match scan_digits(text, p + 1, 2) {
Some(pair) => pair
None => return None
}
p = after_minute
let mut second = 0
if p < n && text[p] == ':' {
match scan_digits(text, p + 1, 2) {
Some((value, next)) => {
second = value
p = next
}
None => ()
}
}
let mut hour = hour_raw
let mut q = p
if q < n && text[q] == ' ' {
q += 1
}
if q + 1 < n && (text[q] == 'a' || text[q] == 'p') && text[q + 1] == 'm' {
if text[q] == 'p' && hour_raw < 12 {
hour = hour_raw + 12
}
if text[q] == 'a' && hour_raw == 12 {
hour = 0
}
p = q + 2
}
let tz = match scan_tz_offset(text, p) {
Some((offset, _)) => offset
None => default_tz
}
let epoch_ms = match
civil_to_epoch_ms(year, month, day, hour, minute, second, tz) {
Some(ms) => ms
None => return None
}
Some((epoch_ms, p))
}
///|
fn try_parse_absolute_at(
text : String,
i : Int,
default_tz : Int,
) -> (Int64, Int)? {
match try_iso_at(text, i, default_tz) {
Some(result) => Some(result)
None => parse_english_date_at(text, i, default_tz)
}
}
///|
fn scan_relative_group(text : String, j : Int) -> (Int64, Int)? {
let n = text.length()
if j >= n || !is_digit_code(text[j]) {
return None
}
let mut value : Int64 = 0L
let mut k = j
while k < n && is_digit_code(text[k]) {
value = value * 10L + digit_value(text[k]).to_int64()
k += 1
}
if k < n && text[k] == ' ' {
k += 1
}
let unit_start = k
while k < n && text[k] >= 'a' && text[k] <= 'z' {
k += 1
}
if k == unit_start {
return None
}
let unit = text.exact_view(start=unit_start, end=k).to_owned()
let unit_ms : Int64 = if unit == "hour" || unit == "hours" || unit == "h" {
3600000L
} else if unit == "minute" ||
unit == "minutes" ||
unit == "min" ||
unit == "m" {
60000L
} else if unit == "second" || unit == "seconds" || unit == "s" {
1000L
} else if unit == "day" || unit == "days" || unit == "d" {
86400000L
} else {
return None
}
Some((value * unit_ms, k))
}
///|
/// "in 8 hours 30 minutes" / "in 13 minutes" / "in 2h 5m" anywhere in text.
fn try_parse_relative(text : String, now_ms~ : Int64) -> Int64? {
let n = text.length()
let mut i = 0
while i + 3 < n {
if text[i] == 'i' && text[i + 1] == 'n' && text[i + 2] == ' ' {
let mut j = i + 3
while j < n && text[j] == ' ' {
j += 1
}
if j < n && is_digit_code(text[j]) {
let mut total_ms : Int64 = 0L
let mut groups = 0
let mut k = j
while groups < 3 {
while k < n && text[k] == ' ' {
k += 1
}
if k + 4 <= n &&
text[k] == 'a' &&
text[k + 1] == 'n' &&
text[k + 2] == 'd' &&
text[k + 3] == ' ' {
k += 4
while k < n && text[k] == ' ' {
k += 1
}
}
match scan_relative_group(text, k) {
Some((group_ms, next)) => {
total_ms += group_ms
groups += 1
k = next
}
None => break
}
}
if groups > 0 {
return Some(now_ms + total_ms)
}
}
}
i += 1
}
None
}
///|
/// A `Retry-After` value: delta-seconds ("120") or a timestamp string.
fn parse_retry_after_value(value : String, now_ms~ : Int64) -> Int64? {
let trimmed = value.trim().to_owned()
let mut all_digits = trimmed.length() > 0
for i in 0..= 0 && secs <= 86400 * 14 {
return Some(now_ms + secs * 1000L)
}
return None
}
parse_reset_timestamp(trimmed, now_ms~)
}
///|
/// Parse a provider-stated reset time out of free text. Supported shapes:
/// - `2026-08-21T15:04:16Z`, `2026-08-21 15:04:16`, `2026-08-21 15:04:16(UTC+8)`
/// - `Aug 13, 2026 at 15:52`, `Aug 13, 2026 at 3:45pm`
/// - relative: `in 8 hours 30 minutes`, `in 13 minutes`, `in 45 seconds`
///
/// `default_tz_offset_minutes~` applies only when the text carries no explicit
/// timezone (z.ai flush times are UTC+8; a generic gateway's are usually
/// UTC). Returns epoch milliseconds.
pub fn parse_reset_timestamp(
text : String,
default_tz_offset_minutes? : Int = 0,
now_ms~ : Int64,
) -> Int64? {
let n = text.length()
let mut i = 0
while i < n {
let code = text[i]
if is_digit_code(code) ||
(code >= 'A' && code <= 'Z') ||
(code >= 'a' && code <= 'z') {
match try_parse_absolute_at(text, i, default_tz_offset_minutes) {
Some((ms, _)) => return Some(ms)
None => ()
}
}
i += 1
}
try_parse_relative(text, now_ms~)
}
///|
fn lower_code(code : UInt16) -> UInt16 {
if code >= 'A' && code <= 'Z' {
code - 'A' + 'a'
} else {
code
}
}
///|
/// Case-insensitive ASCII keyword match at `i`.
fn keyword_starts_at(text : String, i : Int, keyword : String) -> Bool {
if i < 0 || i + keyword.length() > text.length() {
return false
}
for k in 0.. Bool {
let n = text.length()
let mut i = 0
while i + keyword.length() <= n {
if keyword_starts_at(text, i, keyword) {
return true
}
i += 1
}
false
}
///|
/// Whether a non-429 error body reads as a quota verdict. Kimi answers
/// coding-plan quota exhaustion with 403, so status alone cannot gate: the
/// provider's own phrasing ("usage limit", "quota will reset") is the
/// discriminator; auth failures ("Invalid API key") never match.
fn body_carries_quota_verdict(text : String) -> Bool {
let markers : Array[String] = [
"usage limit", "usage_limit", "rate limit", "rate_limit", "quota",
]
for marker in markers {
if case_insensitive_contains(text, marker) {
return true
}
}
false
}
///|
/// Quota-window length named by the message ("5-hour usage limit",
/// "current 5-hour window ends", "3 day period"): `N` +
/// `hour|hours|minute|minutes|day|days` with a window keyword nearby. The
/// shape requires digits and a unit word, so token-count phrasing ("128k
/// context window") never matches.
fn window_length_ms(text : String) -> Int64? {
let n = text.length()
let mut i = 0
while i < n {
if !is_digit_code(text[i]) {
i += 1
continue
}
let mut value : Int64 = 0L
while i < n && is_digit_code(text[i]) {
value = value * 10L + digit_value(text[i]).to_int64()
i += 1
}
if i < n && (text[i] == '-' || text[i] == ' ') {
i += 1
}
let unit_start = i
while i < n && text[i] >= 'a' && text[i] <= 'z' {
i += 1
}
if i == unit_start {
continue
}
let unit = text.exact_view(start=unit_start, end=i).to_owned()
let unit_ms : Int64 = if unit == "hour" || unit == "hours" {
3600000L
} else if unit == "minute" || unit == "minutes" {
60000L
} else if unit == "day" || unit == "days" {
86400000L
} else {
0L
}
if unit_ms > 0L && value > 0L && value <= 366L {
let lookahead = if i + 24 < n { i + 24 } else { n }
let mut j = i
while j < lookahead {
if keyword_starts_at(text, j, "window") ||
keyword_starts_at(text, j, "usage limit") ||
keyword_starts_at(text, j, "period") {
return Some(value * unit_ms)
}
j += 1
}
}
}
None
}
///|
/// Window-length fallback for verdicts that name the window but no
/// timestamp: the true reset is at or before `now + length`, so scheduling
/// at that upper bound never resumes early.
fn parse_window_reset(text : String, now_ms~ : Int64) -> Int64? {
match window_length_ms(text) {
Some(length) => Some(now_ms + length)
None => None
}
}
///|
/// Epoch seconds, or milliseconds when the value is that large.
fn json_number_as_epoch_ms(value : Double) -> Int64? {
if value < 0 || value > 4102444800000.0 { // year 2100
return None
}
let seconds = if value >= 100000000000.0 { value / 1000.0 } else { value }
let secs_int = seconds.to_int()
if secs_int < 0 {
return None
}
Some(secs_int.to_int64() * 1000L)
}
///|
fn json_field(container : Json, key : String) -> Json? {
match container {
Json::Object(fields) => fields.get(key)
_ => None
}
}
///|
fn json_string_value(json : Json) -> String? {
match json {
Json::String(value) => Some(value)
_ => None
}
}
///|
/// Render a provider error code that may arrive as a JSON string ("1308") or
/// number (1302) into its display string.
fn provider_code_string(json : Json) -> String? {
match json {
Json::String(value) => Some(value)
Json::Number(value, ..) => {
let as_int = value.to_int()
if as_int.to_double() == value {
Some("\{as_int}")
} else {
None
}
}
_ => None
}
}
///|
fn bounded_body_excerpt(text : String) -> String {
let chars : Array[Char] = []
let mut truncated = false
for char in text {
if chars.length() >= 160 {
truncated = true
break
}
match char {
'\n' | '\r' => chars.push(' ')
other => chars.push(other)
}
}
let label = String::from_array(chars)
if truncated {
label + "…"
} else {
label
}
}
///|
fn first_present(containers : Array[Json], keys : Array[String]) -> Json? {
for container in containers {
for key in keys {
match json_field(container, key) {
Some(value) => return Some(value)
None => ()
}
}
}
None
}
///|
/// Classify an HTTP status + response body pair. Returns
/// `Some(ModelError::RateLimited(info))` for 429 verdicts and, like Kimi's
/// coding plan, 403 verdicts whose body phrasing reads as a quota wall;
/// `None` otherwise — the caller keeps its existing Transport formatting for
/// every other non-2xx status. `reset_at_ms` inside the info is `None` when
/// the provider stated no schedulable reset time (transient concurrency
/// limits); such verdicts are not schedulable and the caller should back off
/// or fail.
///
/// Extraction order (first hit wins):
/// 1. `resets_in_seconds` — relative seconds from now (OpenAI/Codex shape)
/// 2. `resets_at` / `reset_at` — epoch seconds (or milliseconds)
/// 3. `retry_after` / `retry-after` — seconds, or a timestamp string
/// 4. the `Retry-After` HTTP header, when the caller can read one (the
/// documented wait signal for Kimi/Moonshot verdicts, whose message text
/// carries no timestamp)
/// 5. the provider message text (z.ai "{next_flush_time}", Codex English
/// dates, relative "in N hours")
/// 6. the named quota-window length ("5-hour usage limit") — an upper bound
/// on the true reset time
///
/// Lookup covers both the nested `error` object and the top level. When the
/// body is not JSON at all, only paths 4-6 apply against the raw text.
pub fn classify_chat_completions_http_error(
status : Int,
body_text : String,
default_tz_offset_minutes? : Int = 0,
retry_after_header? : String? = None,
now_ms~ : Int64,
) -> @posoco.ModelError? {
let body : Json = @json.parse(body_text) catch { _ => Json::null() }
let err : Json = match json_field(body, "error") {
Some(inner) =>
match inner {
Json::Object(_) => inner
_ => body
}
None => body
}
let scopes : Array[Json] = [err, body]
let provider_code = match first_present(scopes, ["code", "type"]) {
Some(code_json) =>
match provider_code_string(code_json) {
Some(code) => Some(code)
None => None
}
None => None
}
let message = match first_present(scopes, ["message"]) {
Some(message_json) =>
match json_string_value(message_json) {
Some(value) => value
None => bounded_body_excerpt(body_text)
}
None => bounded_body_excerpt(body_text)
}
if status != 429 {
// Kimi's coding plan answers quota exhaustion with 403; every other
// non-429 (auth failures, provider bugs) keeps the caller's Transport
// path unless the body itself reads as a quota verdict.
let code_carries_quota = match provider_code {
Some(code) => body_carries_quota_verdict(code)
None => false
}
if status != 403 ||
!(body_carries_quota_verdict(message) || code_carries_quota) {
return None
}
}
let mut reset_at_ms : Int64? = None
match first_present(scopes, ["resets_in_seconds"]) {
Some(Json::Number(value, ..)) => {
let secs = value.to_int()
if secs >= 0 && secs <= 86400 * 14 {
reset_at_ms = Some(now_ms + secs.to_int64() * 1000L)
}
}
_ => ()
}
if reset_at_ms is None {
match first_present(scopes, ["resets_at", "reset_at"]) {
Some(Json::Number(value, ..)) =>
reset_at_ms = json_number_as_epoch_ms(value)
Some(Json::String(value)) =>
reset_at_ms = parse_reset_timestamp(
value,
default_tz_offset_minutes~,
now_ms~,
)
_ => ()
}
}
if reset_at_ms is None {
match first_present(scopes, ["retry_after", "retry-after"]) {
Some(Json::Number(value, ..)) => {
let secs = value.to_int()
if secs >= 0 && secs <= 86400 * 14 {
reset_at_ms = Some(now_ms + secs.to_int64() * 1000L)
}
}
Some(Json::String(value)) =>
reset_at_ms = parse_reset_timestamp(
value,
default_tz_offset_minutes~,
now_ms~,
)
_ => ()
}
}
if reset_at_ms is None {
match retry_after_header {
Some(value) if value.trim().length() > 0 =>
reset_at_ms = parse_retry_after_value(value, now_ms~)
_ => ()
}
}
if reset_at_ms is None && message.length() > 0 {
reset_at_ms = parse_reset_timestamp(
message,
default_tz_offset_minutes~,
now_ms~,
)
if reset_at_ms is None {
// Kimi names the window ("5-hour usage limit") but no timestamp: the
// window length is an upper bound on the true reset time.
reset_at_ms = parse_window_reset(message, now_ms~)
}
}
Some(
@posoco.ModelError::RateLimited(@posoco.RateLimitInfo::{
message,
reset_at_ms,
provider_code,
}),
)
}