Compare commits

...

5 Commits

Author SHA1 Message Date
Thibault Sottiaux
720c69bd03 Update protocol.rs 2025-10-20 11:58:05 -07:00
Thibault Sottiaux
f5af9cc8ba Merge branch 'main' into tibo/rate-limit-unix-reset 2025-10-20 11:57:23 -07:00
Thibault Sottiaux
722826afff tests: mock usage limit with RFC reset timestamp 2025-10-20 10:19:47 -07:00
Thibault Sottiaux
af3552d428 Update client.rs 2025-10-20 09:40:27 -07:00
Thibault Sottiaux
211b681499 Use Unix seconds for rate-limit reset timestamps 2025-10-20 09:38:21 -07:00
6 changed files with 54 additions and 21 deletions

View File

@@ -53,6 +53,7 @@ use crate::state::TaskKind;
use crate::token_data::PlanType;
use crate::util::backoff;
use chrono::DateTime;
use chrono::TimeZone;
use chrono::Utc;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
@@ -76,6 +77,19 @@ struct Error {
resets_at: Option<String>,
}
fn parse_reset_timestamp(value: Option<&str>) -> Option<DateTime<Utc>> {
value.and_then(|raw| {
raw.parse::<i64>()
.ok()
.and_then(|secs| Utc.timestamp_opt(secs, 0).single())
.or_else(|| {
DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.with_timezone(&Utc))
.ok()
})
})
}
#[derive(Debug, Clone)]
pub struct ModelClient {
config: Arc<Config>,
@@ -423,11 +437,7 @@ impl ModelClient {
let plan_type = error
.plan_type
.or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type));
let resets_at = error
.resets_at
.as_deref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|dt| dt.with_timezone(&Utc));
let resets_at = parse_reset_timestamp(error.resets_at.as_deref());
let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type,
resets_at,
@@ -639,7 +649,13 @@ fn parse_rate_limit_window(
let resets_at = parse_header_str(headers, resets_header)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(std::string::ToString::to_string);
.and_then(|value| {
value.parse::<i64>().ok().or_else(|| {
DateTime::parse_from_rfc3339(value)
.map(|dt| dt.timestamp())
.ok()
})
});
let has_data = used_percent != 0.0
|| window_minutes.is_some_and(|minutes| minutes != 0)
@@ -1430,6 +1446,10 @@ mod tests {
let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema");
assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro)));
assert_eq!(
resp.error.resets_at.as_deref(),
Some("2024-01-01T00:00:00Z")
);
let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type");
assert_eq!(plan_json, "\"pro\"");
@@ -1443,6 +1463,10 @@ mod tests {
let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema");
assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip");
assert_eq!(
resp.error.resets_at.as_deref(),
Some("2024-01-01T00:01:00Z")
);
let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type");
assert_eq!(plan_json, "\"vip\"");

View File

@@ -425,12 +425,20 @@ mod tests {
primary: Some(RateLimitWindow {
used_percent: 50.0,
window_minutes: Some(60),
resets_at: Some("2024-01-01T01:00:00Z".to_string()),
resets_at: Some(
Utc.with_ymd_and_hms(2024, 1, 1, 1, 0, 0)
.unwrap()
.timestamp(),
),
}),
secondary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(120),
resets_at: Some("2024-01-01T02:00:00Z".to_string()),
resets_at: Some(
Utc.with_ymd_and_hms(2024, 1, 1, 2, 0, 0)
.unwrap()
.timestamp(),
),
}),
}
}

View File

@@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() {
"primary": {
"used_percent": 12.5,
"window_minutes": 10,
"resets_at": "2024-01-01T00:30:00Z"
"resets_at": 1704069000
},
"secondary": {
"used_percent": 40.0,
"window_minutes": 60,
"resets_at": "2024-01-01T02:00:00Z"
"resets_at": 1704074400
}
}
})
@@ -865,12 +865,12 @@ async fn token_count_includes_rate_limits_snapshot() {
"primary": {
"used_percent": 12.5,
"window_minutes": 10,
"resets_at": "2024-01-01T00:30:00Z"
"resets_at": 1704069000
},
"secondary": {
"used_percent": 40.0,
"window_minutes": 60,
"resets_at": "2024-01-01T02:00:00Z"
"resets_at": 1704074400
}
}
})
@@ -893,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() {
final_snapshot
.primary
.as_ref()
.and_then(|window| window.resets_at.as_deref()),
Some("2024-01-01T00:30:00Z")
.and_then(|window| window.resets_at),
Some(1704069000)
);
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await;

View File

@@ -658,9 +658,9 @@ pub struct RateLimitWindow {
/// Rolling window duration, in minutes.
#[ts(type = "number | null")]
pub window_minutes: Option<i64>,
/// Timestamp (RFC3339) when the window resets.
#[ts(type = "string | null")]
pub resets_at: Option<String>,
/// Timestamp (Unix seconds) when the window resets.
#[ts(type = "number | null")]
pub resets_at: Option<i64>,
}
// Includes prompts, tools and space to call compact.

View File

@@ -3,6 +3,8 @@ use crate::chatwidget::get_limits_duration;
use super::helpers::format_reset_timestamp;
use chrono::DateTime;
use chrono::Local;
use chrono::TimeZone;
use chrono::Utc;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
@@ -34,8 +36,7 @@ impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window
.resets_at
.as_deref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.and_then(|value| Utc.timestamp_opt(value, 0).single())
.map(|dt| dt.with_timezone(&Local))
.map(|dt| format_reset_timestamp(dt, captured_at));

View File

@@ -62,10 +62,10 @@ fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
.collect()
}
fn reset_at_from(captured_at: &chrono::DateTime<chrono::Local>, seconds: i64) -> String {
fn reset_at_from(captured_at: &chrono::DateTime<chrono::Local>, seconds: i64) -> i64 {
(*captured_at + ChronoDuration::seconds(seconds))
.with_timezone(&Utc)
.to_rfc3339()
.timestamp()
}
#[test]