Skip to content

Commit 26a77c3

Browse files
authored
Merge branch 'main' into add-github-action-for-nix
2 parents c1dbef4 + 274d9b4 commit 26a77c3

File tree

43 files changed

+1516
-894
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1516
-894
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ In the codex-rs folder where the rust code lives:
1111
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests:
1212
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
1313
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`.
14+
When running interactively, ask the user before running these commands to finalize.
1415

1516
## TUI style conventions
1617

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/core/src/client.rs

Lines changed: 32 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
use std::io::BufRead;
22
use std::path::Path;
3-
use std::sync::OnceLock;
43
use std::time::Duration;
54

65
use bytes::Bytes;
76
use codex_login::AuthManager;
87
use codex_login::AuthMode;
98
use eventsource_stream::Eventsource;
109
use futures::prelude::*;
11-
use regex_lite::Regex;
1210
use reqwest::StatusCode;
1311
use serde::Deserialize;
1412
use serde::Serialize;
@@ -54,8 +52,11 @@ struct ErrorResponse {
5452
#[derive(Debug, Deserialize)]
5553
struct Error {
5654
r#type: Option<String>,
57-
code: Option<String>,
5855
message: Option<String>,
56+
57+
// Optional fields available on "usage_limit_reached" and "usage_not_included" errors
58+
plan_type: Option<String>,
59+
resets_in_seconds: Option<u64>,
5960
}
6061

6162
#[derive(Debug, Clone)]
@@ -142,9 +143,12 @@ impl ModelClient {
142143
}
143144

144145
let auth_manager = self.auth_manager.clone();
145-
let auth = auth_manager.as_ref().and_then(|m| m.auth());
146146

147-
let auth_mode = auth.as_ref().map(|a| a.mode);
147+
let auth_mode = auth_manager
148+
.as_ref()
149+
.and_then(|m| m.auth())
150+
.as_ref()
151+
.map(|a| a.mode);
148152

149153
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
150154

@@ -211,15 +215,18 @@ impl ModelClient {
211215
let mut attempt = 0;
212216
let max_retries = self.provider.request_max_retries();
213217

214-
trace!(
215-
"POST to {}: {}",
216-
self.provider.get_full_url(&auth),
217-
serde_json::to_string(&payload)?
218-
);
219-
220218
loop {
221219
attempt += 1;
222220

221+
// Always fetch the latest auth in case a prior attempt refreshed the token.
222+
let auth = auth_manager.as_ref().and_then(|m| m.auth());
223+
224+
trace!(
225+
"POST to {}: {}",
226+
self.provider.get_full_url(&auth),
227+
serde_json::to_string(&payload)?
228+
);
229+
223230
let mut req_builder = self
224231
.provider
225232
.create_request_builder(&self.client, &auth)
@@ -303,19 +310,20 @@ impl ModelClient {
303310

304311
if status == StatusCode::TOO_MANY_REQUESTS {
305312
let body = res.json::<ErrorResponse>().await.ok();
306-
if let Some(ErrorResponse {
307-
error:
308-
Error {
309-
r#type: Some(error_type),
310-
..
311-
},
312-
}) = body
313-
{
314-
if error_type == "usage_limit_reached" {
313+
if let Some(ErrorResponse { error }) = body {
314+
if error.r#type.as_deref() == Some("usage_limit_reached") {
315+
// Prefer the plan_type provided in the error message if present
316+
// because it's more up to date than the one encoded in the auth
317+
// token.
318+
let plan_type = error
319+
.plan_type
320+
.or_else(|| auth.and_then(|a| a.get_plan_type()));
321+
let resets_in_seconds = error.resets_in_seconds;
315322
return Err(CodexErr::UsageLimitReached(UsageLimitReachedError {
316-
plan_type: auth.and_then(|a| a.get_plan_type()),
323+
plan_type,
324+
resets_in_seconds,
317325
}));
318-
} else if error_type == "usage_not_included" {
326+
} else if error.r#type.as_deref() == Some("usage_not_included") {
319327
return Err(CodexErr::UsageNotIncluded);
320328
}
321329
}
@@ -563,9 +571,8 @@ async fn process_sse<S>(
563571
if let Some(error) = error {
564572
match serde_json::from_value::<Error>(error.clone()) {
565573
Ok(error) => {
566-
let delay = try_parse_retry_after(&error);
567574
let message = error.message.unwrap_or_default();
568-
response_error = Some(CodexErr::Stream(message, delay));
575+
response_error = Some(CodexErr::Stream(message, None));
569576
}
570577
Err(e) => {
571578
debug!("failed to parse ErrorResponse: {e}");
@@ -653,40 +660,6 @@ async fn stream_from_fixture(
653660
Ok(ResponseStream { rx_event })
654661
}
655662

656-
fn rate_limit_regex() -> &'static Regex {
657-
static RE: OnceLock<Regex> = OnceLock::new();
658-
659-
#[expect(clippy::unwrap_used)]
660-
RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap())
661-
}
662-
663-
fn try_parse_retry_after(err: &Error) -> Option<Duration> {
664-
if err.code != Some("rate_limit_exceeded".to_string()) {
665-
return None;
666-
}
667-
668-
// parse the Please try again in 1.898s format using regex
669-
let re = rate_limit_regex();
670-
if let Some(message) = &err.message
671-
&& let Some(captures) = re.captures(message)
672-
{
673-
let seconds = captures.get(1);
674-
let unit = captures.get(2);
675-
676-
if let (Some(value), Some(unit)) = (seconds, unit) {
677-
let value = value.as_str().parse::<f64>().ok()?;
678-
let unit = unit.as_str();
679-
680-
if unit == "s" {
681-
return Some(Duration::from_secs_f64(value));
682-
} else if unit == "ms" {
683-
return Some(Duration::from_millis(value as u64));
684-
}
685-
}
686-
}
687-
None
688-
}
689-
690663
#[cfg(test)]
691664
mod tests {
692665
use super::*;
@@ -907,7 +880,7 @@ mod tests {
907880
msg,
908881
"Rate limit reached for gpt-5 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."
909882
);
910-
assert_eq!(*delay, Some(Duration::from_secs_f64(11.054)));
883+
assert_eq!(*delay, None);
911884
}
912885
other => panic!("unexpected second event: {other:?}"),
913886
}
@@ -1011,27 +984,4 @@ mod tests {
1011984
);
1012985
}
1013986
}
1014-
1015-
#[test]
1016-
fn test_try_parse_retry_after() {
1017-
let err = Error {
1018-
r#type: None,
1019-
message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
1020-
code: Some("rate_limit_exceeded".to_string()),
1021-
};
1022-
1023-
let delay = try_parse_retry_after(&err);
1024-
assert_eq!(delay, Some(Duration::from_millis(28)));
1025-
}
1026-
1027-
#[test]
1028-
fn test_try_parse_retry_after_no_delay() {
1029-
let err = Error {
1030-
r#type: None,
1031-
message: Some("Rate limit reached for gpt-5 in organization <ORG> on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
1032-
code: Some("rate_limit_exceeded".to_string()),
1033-
};
1034-
let delay = try_parse_retry_after(&err);
1035-
assert_eq!(delay, Some(Duration::from_secs_f64(1.898)));
1036-
}
1037987
}

codex-rs/core/src/codex.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,7 @@ async fn submission_loop(
11521152
if let Err(items) = sess.inject_input(items) {
11531153
// Derive a fresh TurnContext for this turn using the provided overrides.
11541154
let provider = turn_context.client.get_provider();
1155+
let auth_manager = turn_context.client.get_auth_manager();
11551156

11561157
// Derive a model family for the requested model; fall back to the session's.
11571158
let model_family = find_family_for_model(&model)
@@ -1166,7 +1167,7 @@ async fn submission_loop(
11661167
// Reuse the same provider and session id; auth defaults to env/API key.
11671168
let client = ModelClient::new(
11681169
Arc::new(per_turn_config),
1169-
None,
1170+
auth_manager,
11701171
provider,
11711172
effort,
11721173
summary,

codex-rs/core/src/error.rs

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,27 +128,70 @@ pub enum CodexErr {
128128
#[derive(Debug)]
129129
pub struct UsageLimitReachedError {
130130
pub plan_type: Option<String>,
131+
pub resets_in_seconds: Option<u64>,
131132
}
132133

133134
impl std::fmt::Display for UsageLimitReachedError {
134135
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136+
// Base message differs slightly for legacy ChatGPT Plus plan users.
135137
if let Some(plan_type) = &self.plan_type
136138
&& plan_type == "plus"
137139
{
138140
write!(
139141
f,
140-
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
142+
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again"
141143
)?;
144+
if let Some(secs) = self.resets_in_seconds {
145+
let reset_duration = format_reset_duration(secs);
146+
write!(f, " in {reset_duration}.")?;
147+
} else {
148+
write!(f, " later.")?;
149+
}
142150
} else {
143-
write!(
144-
f,
145-
"You've hit your usage limit. Limits reset every 5h and every week."
146-
)?;
151+
write!(f, "You've hit your usage limit.")?;
152+
153+
if let Some(secs) = self.resets_in_seconds {
154+
let reset_duration = format_reset_duration(secs);
155+
write!(f, " Try again in {reset_duration}.")?;
156+
} else {
157+
write!(f, " Try again later.")?;
158+
}
147159
}
160+
148161
Ok(())
149162
}
150163
}
151164

165+
fn format_reset_duration(total_secs: u64) -> String {
166+
let days = total_secs / 86_400;
167+
let hours = (total_secs % 86_400) / 3_600;
168+
let minutes = (total_secs % 3_600) / 60;
169+
170+
let mut parts: Vec<String> = Vec::new();
171+
if days > 0 {
172+
let unit = if days == 1 { "day" } else { "days" };
173+
parts.push(format!("{} {}", days, unit));
174+
}
175+
if hours > 0 {
176+
let unit = if hours == 1 { "hour" } else { "hours" };
177+
parts.push(format!("{} {}", hours, unit));
178+
}
179+
if minutes > 0 {
180+
let unit = if minutes == 1 { "minute" } else { "minutes" };
181+
parts.push(format!("{} {}", minutes, unit));
182+
}
183+
184+
if parts.is_empty() {
185+
return "less than a minute".to_string();
186+
}
187+
188+
match parts.len() {
189+
1 => parts[0].clone(),
190+
2 => format!("{} {}", parts[0], parts[1]),
191+
_ => format!("{} {} {}", parts[0], parts[1], parts[2]),
192+
}
193+
}
194+
152195
#[derive(Debug)]
153196
pub struct EnvVarError {
154197
/// Name of the environment variable that is missing.
@@ -181,6 +224,8 @@ impl CodexErr {
181224
pub fn get_error_message_ui(e: &CodexErr) -> String {
182225
match e {
183226
CodexErr::Sandbox(SandboxErr::Denied(_, _, stderr)) => stderr.to_string(),
227+
// Timeouts are not sandbox errors from a UX perspective; present them plainly
228+
CodexErr::Sandbox(SandboxErr::Timeout) => "error: command timed out".to_string(),
184229
_ => e.to_string(),
185230
}
186231
}
@@ -193,30 +238,83 @@ mod tests {
193238
fn usage_limit_reached_error_formats_plus_plan() {
194239
let err = UsageLimitReachedError {
195240
plan_type: Some("plus".to_string()),
241+
resets_in_seconds: None,
196242
};
197243
assert_eq!(
198244
err.to_string(),
199-
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)."
245+
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again later."
200246
);
201247
}
202248

203249
#[test]
204250
fn usage_limit_reached_error_formats_default_when_none() {
205-
let err = UsageLimitReachedError { plan_type: None };
251+
let err = UsageLimitReachedError {
252+
plan_type: None,
253+
resets_in_seconds: None,
254+
};
206255
assert_eq!(
207256
err.to_string(),
208-
"You've hit your usage limit. Limits reset every 5h and every week."
257+
"You've hit your usage limit. Try again later."
209258
);
210259
}
211260

212261
#[test]
213262
fn usage_limit_reached_error_formats_default_for_other_plans() {
214263
let err = UsageLimitReachedError {
215264
plan_type: Some("pro".to_string()),
265+
resets_in_seconds: None,
266+
};
267+
assert_eq!(
268+
err.to_string(),
269+
"You've hit your usage limit. Try again later."
270+
);
271+
}
272+
273+
#[test]
274+
fn usage_limit_reached_includes_minutes_when_available() {
275+
let err = UsageLimitReachedError {
276+
plan_type: None,
277+
resets_in_seconds: Some(5 * 60),
278+
};
279+
assert_eq!(
280+
err.to_string(),
281+
"You've hit your usage limit. Try again in 5 minutes."
282+
);
283+
}
284+
285+
#[test]
286+
fn usage_limit_reached_includes_hours_and_minutes() {
287+
let err = UsageLimitReachedError {
288+
plan_type: Some("plus".to_string()),
289+
resets_in_seconds: Some(3 * 3600 + 32 * 60),
290+
};
291+
assert_eq!(
292+
err.to_string(),
293+
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes."
294+
);
295+
}
296+
297+
#[test]
298+
fn usage_limit_reached_includes_days_hours_minutes() {
299+
let err = UsageLimitReachedError {
300+
plan_type: None,
301+
resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60),
302+
};
303+
assert_eq!(
304+
err.to_string(),
305+
"You've hit your usage limit. Try again in 2 days 3 hours 5 minutes."
306+
);
307+
}
308+
309+
#[test]
310+
fn usage_limit_reached_less_than_minute() {
311+
let err = UsageLimitReachedError {
312+
plan_type: None,
313+
resets_in_seconds: Some(30),
216314
};
217315
assert_eq!(
218316
err.to_string(),
219-
"You've hit your usage limit. Limits reset every 5h and every week."
317+
"You've hit your usage limit. Try again in less than a minute."
220318
);
221319
}
222320
}

0 commit comments

Comments
 (0)