Merge 6995a8670a into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-22 17:56:00 -07:00
committed by GitHub
21 changed files with 1980 additions and 68 deletions
+5
View File
@@ -7,6 +7,11 @@
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"macos-x86_64": {
"name": "codex-zsh-x86_64-apple-darwin.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"linux-x86_64": {
"name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
+4
View File
@@ -69,6 +69,10 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-15-large
target: x86_64-apple-darwin
variant: macos-15
archive_name: codex-zsh-x86_64-apple-darwin.tar.gz
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
+1
View File
@@ -30,6 +30,7 @@ In the codex-rs folder where the rust code lives:
- Prefer private modules and explicitly exported public crate API.
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
- When working with MCP tool calls, prefer using `codex-rs/codex-mcp/src/mcp_connection_manager.rs` to handle mutation of tools and tool calls. Aim to minimize the footprint of changes and leverage existing abstractions rather than plumbing code through multiple levels of function calls.
- Do not call `reset_client_session` unnecessarily; let the incremental check logic decide whether to reuse the previous request.
- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the
repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change.
- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught
+1
View File
@@ -3771,6 +3771,7 @@ dependencies = [
"codex-features",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-output-truncation",
"codex-utils-pty",
"codex-utils-string",
@@ -64,6 +64,8 @@ const REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(60);
const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration =
std::time::Duration::from_secs(1);
const REMOTE_CONTROL_RECONNECT_BACKOFF_CAP: std::time::Duration =
std::time::Duration::from_secs(30);
struct BoundedOutboundBuffer {
buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque<ServerEnvelope>>,
@@ -514,12 +516,23 @@ impl RemoteControlWebsocket {
} else {
self.status_publisher
.publish_status(RemoteControlConnectionStatus::Errored);
let reconnect_attempt = self.reconnect_attempt.saturating_add(1);
let (reconnect_delay, reconnect_backoff_reset) =
next_reconnect_delay(&mut self.reconnect_attempt);
warn!(
"failed to connect to app-server remote control websocket: {}, err: {}",
remote_control_target.websocket_url, err
websocket_url = %remote_control_target.websocket_url,
error = %err,
reconnect_attempt,
reconnect_delay = ?reconnect_delay,
reconnect_backoff_reset,
"failed to connect to app-server remote control websocket"
);
let reconnect_delay = backoff(self.reconnect_attempt);
self.reconnect_attempt += 1;
if reconnect_backoff_reset {
info!(
reconnect_backoff_cap = ?REMOTE_CONTROL_RECONNECT_BACKOFF_CAP,
"reset app-server remote control websocket reconnect backoff after cap"
);
}
reconnect_delay
};
tokio::select! {
@@ -1036,6 +1049,17 @@ pub(crate) async fn load_remote_control_auth(
})
}
fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) {
let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP);
let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP;
*reconnect_attempt = if reconnect_backoff_reset {
0
} else {
(*reconnect_attempt).saturating_add(1)
};
(reconnect_delay, reconnect_backoff_reset)
}
pub(super) async fn connect_remote_control_websocket(
remote_control_target: &RemoteControlTarget,
state_db: Option<&StateRuntime>,
@@ -1326,6 +1350,26 @@ mod tests {
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5);
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
#[test]
fn next_reconnect_delay_resets_after_cap() {
let mut reconnect_attempt = 9;
let (reconnect_delay, reconnect_backoff_reset) =
next_reconnect_delay(&mut reconnect_attempt);
assert_eq!(reconnect_delay, REMOTE_CONTROL_RECONNECT_BACKOFF_CAP);
assert!(reconnect_backoff_reset);
assert_eq!(reconnect_attempt, 0);
let (reconnect_delay, reconnect_backoff_reset) =
next_reconnect_delay(&mut reconnect_attempt);
assert!(reconnect_delay >= Duration::from_millis(180));
assert!(reconnect_delay <= Duration::from_millis(220));
assert!(!reconnect_backoff_reset);
assert_eq!(reconnect_attempt, 1);
}
fn remote_control_status_channel() -> (
RemoteControlStatusPublisher,
watch::Receiver<RemoteControlStatusChangedNotification>,
+4
View File
@@ -2,6 +2,7 @@ use crate::TransportError;
use crate::error::ApiError;
use crate::rate_limits::parse_promo_message;
use crate::rate_limits::parse_rate_limit_for_limit;
use crate::rate_limits::parse_rate_limit_reached_type;
use base64::Engine;
use chrono::DateTime;
use chrono::Utc;
@@ -85,6 +86,8 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
parse_rate_limit_for_limit(map, limit_id.as_deref())
});
let promo_message = headers.as_ref().and_then(parse_promo_message);
let rate_limit_reached_type =
headers.as_ref().and_then(parse_rate_limit_reached_type);
let resets_at = err
.error
.resets_at
@@ -94,6 +97,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
resets_at,
rate_limits: rate_limits.map(Box::new),
promo_message,
rate_limit_reached_type,
});
} else if err.error.error_type.as_deref() == Some("usage_not_included") {
return CodexErr::UsageNotIncluded;
@@ -194,6 +194,37 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
);
}
#[test]
fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() {
let values = [
http::HeaderValue::from_static("future_rate_limit_reached_type"),
http::HeaderValue::from_bytes(&[0xff]).expect("valid opaque header value"),
];
for value in values {
let mut headers = HeaderMap::new();
headers.insert("x-codex-rate-limit-reached-type", value);
let body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
body: Some(body),
}));
let CodexErr::UsageLimitReached(usage_limit) = err else {
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
};
assert_eq!(usage_limit.rate_limit_reached_type, None);
}
}
#[test]
fn map_api_error_extracts_identity_auth_details_from_headers() {
let mut headers = HeaderMap::new();
+8
View File
@@ -1,5 +1,6 @@
use codex_protocol::account::PlanType;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use http::HeaderMap;
@@ -178,6 +179,13 @@ pub fn parse_promo_message(headers: &HeaderMap) -> Option<String> {
.map(std::string::ToString::to_string)
}
pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option<RateLimitReachedType> {
parse_header_str(headers, "x-codex-rate-limit-reached-type")?
.trim()
.parse()
.ok()
}
fn parse_rate_limit_window(
headers: &HeaderMap,
used_percent_header: &str,
+1 -1
View File
@@ -938,7 +938,7 @@ impl Drop for ModelClientSession {
}
impl ModelClientSession {
pub(crate) fn reset_websocket_session(&mut self) {
fn reset_websocket_session(&mut self) {
self.websocket_session.connection = None;
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
+38 -63
View File
@@ -142,25 +142,18 @@ pub(crate) async fn run_turn(
// new user message are recorded. Estimate pending incoming items (context
// diffs/full reinjection + user input) and trigger compaction preemptively
// when they would push the thread over the compaction threshold.
let pre_sampling_compact =
match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
Ok(pre_sampling_compact) => pre_sampling_compact,
Err(err) => {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!("failed to usage-limit active goal after usage-limit error: {err}");
}
error!("Failed to run pre-sampling compact");
return None;
}
};
if pre_sampling_compact.reset_client_session {
client_session.reset_websocket_session();
if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!("failed to usage-limit active goal after usage-limit error: {err}");
}
error!("Failed to run pre-sampling compact");
return None;
}
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
@@ -287,7 +280,7 @@ pub(crate) async fn run_turn(
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached && needs_follow_up {
let reset_client_session = match run_auto_compact(
if let Err(err) = run_auto_compact(
&sess,
&turn_context,
&mut client_session,
@@ -297,24 +290,18 @@ pub(crate) async fn run_turn(
)
.await
{
Ok(reset_client_session) => reset_client_session,
Err(err) => {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!(
"failed to usage-limit active goal after usage-limit error: {err}"
);
}
return None;
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!(
"failed to usage-limit active goal after usage-limit error: {err}"
);
}
};
if reset_client_session {
client_session.reset_websocket_session();
return None;
}
can_drain_pending_input = !model_needs_follow_up;
continue;
@@ -636,10 +623,6 @@ async fn track_turn_resolved_config_analytics(
});
}
struct PreSamplingCompactResult {
reset_client_session: bool,
}
#[derive(Debug)]
struct AutoCompactTokenStatus {
// Full active context usage, independent of the configured auto-compact scope.
@@ -710,14 +693,12 @@ async fn run_pre_sampling_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
) -> CodexResult<PreSamplingCompactResult> {
let mut pre_sampling_compacted =
maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?;
let mut reset_client_session = pre_sampling_compacted;
) -> CodexResult<()> {
maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?;
let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
// Compact if the configured auto-compaction budget or usable context window is exhausted.
if token_status.token_limit_reached {
reset_client_session |= run_auto_compact(
run_auto_compact(
sess,
turn_context,
client_session,
@@ -726,26 +707,21 @@ async fn run_pre_sampling_compact(
CompactionPhase::PreTurn,
)
.await?;
pre_sampling_compacted = true;
}
Ok(PreSamplingCompactResult {
reset_client_session: pre_sampling_compacted && reset_client_session,
})
Ok(())
}
/// Runs pre-sampling compaction against the previous model when switching to a smaller
/// context-window model.
///
/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped
/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction
/// was attempted and failed.
/// Returns `Err(_)` only when compaction was attempted and failed.
async fn maybe_run_previous_model_inline_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
) -> CodexResult<bool> {
) -> CodexResult<()> {
let Some(previous_turn_settings) = sess.previous_turn_settings().await else {
return Ok(false);
return Ok(());
};
let previous_model_turn_context = Arc::new(
turn_context
@@ -754,10 +730,10 @@ async fn maybe_run_previous_model_inline_compact(
);
let Some(old_context_window) = previous_model_turn_context.model_context_window() else {
return Ok(false);
return Ok(());
};
let Some(new_context_window) = turn_context.model_context_window() else {
return Ok(false);
return Ok(());
};
let active_context_tokens = sess.get_total_token_usage().await;
let previous_model_limit_reached = match turn_context
@@ -778,7 +754,7 @@ async fn maybe_run_previous_model_inline_compact(
&& previous_model_turn_context.model_info.slug != turn_context.model_info.slug
&& old_context_window > new_context_window;
if should_run {
let _ = run_auto_compact(
run_auto_compact(
sess,
&previous_model_turn_context,
client_session,
@@ -787,9 +763,8 @@ async fn maybe_run_previous_model_inline_compact(
CompactionPhase::PreTurn,
)
.await?;
return Ok(true);
}
Ok(false)
Ok(())
}
async fn run_auto_compact(
@@ -799,7 +774,7 @@ async fn run_auto_compact(
initial_context_injection: InitialContextInjection,
reason: CompactionReason,
phase: CompactionPhase,
) -> CodexResult<bool> {
) -> CodexResult<()> {
if should_use_remote_compact_task(turn_context.provider.info()) {
if turn_context.features.enabled(Feature::RemoteCompactionV2) {
run_inline_remote_auto_compact_task_v2(
@@ -811,7 +786,7 @@ async fn run_auto_compact(
phase,
)
.await?;
return Ok(false);
return Ok(());
}
run_inline_remote_auto_compact_task(
Arc::clone(sess),
@@ -831,7 +806,7 @@ async fn run_auto_compact(
)
.await?;
}
Ok(true)
Ok(())
}
pub(super) fn collect_explicit_app_ids_from_skill_items(
+34
View File
@@ -7,6 +7,7 @@ use crate::exec_output::ExecToolCallOutput;
use crate::network_policy::NetworkPolicyDecisionPayload;
use crate::protocol::CodexErrorInfo;
use crate::protocol::ErrorEvent;
use crate::protocol::RateLimitReachedType;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TruncationPolicy;
use chrono::DateTime;
@@ -451,6 +452,7 @@ pub struct UsageLimitReachedError {
pub resets_at: Option<DateTime<Utc>>,
pub rate_limits: Option<Box<RateLimitSnapshot>>,
pub promo_message: Option<String>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
impl std::fmt::Display for UsageLimitReachedError {
@@ -470,6 +472,38 @@ impl std::fmt::Display for UsageLimitReachedError {
);
}
if let Some(rate_limit_reached_type) = self.rate_limit_reached_type {
match rate_limit_reached_type {
RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
return write!(
f,
"Your workspace is out of credits. Add credits to continue."
);
}
RateLimitReachedType::WorkspaceMemberCreditsDepleted => {
return write!(
f,
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue."
);
}
RateLimitReachedType::WorkspaceOwnerUsageLimitReached => {
return write!(
f,
"You hit your spend cap set in your workspace. Increase your spend cap to continue."
);
}
RateLimitReachedType::WorkspaceMemberUsageLimitReached => {
return write!(
f,
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue."
);
}
RateLimitReachedType::RateLimitReached => {
// Generic limits intentionally use the existing promo or plan copy below.
}
}
}
if let Some(promo_message) = &self.promo_message {
return write!(
f,
+54
View File
@@ -56,6 +56,7 @@ fn usage_limit_reached_error_formats_plus_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -63,6 +64,44 @@ fn usage_limit_reached_error_formats_plus_plan() {
);
}
#[test]
fn usage_limit_reached_error_formats_rate_limit_reached_types() {
let cases = [
(
RateLimitReachedType::RateLimitReached,
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.",
),
(
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
"Your workspace is out of credits. Add credits to continue.",
),
(
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue.",
),
(
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
"You hit your spend cap set in your workspace. Increase your spend cap to continue.",
),
(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.",
),
];
for (rate_limit_reached_type, expected) in cases {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: Some(rate_limit_reached_type),
};
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn server_overloaded_maps_to_protocol() {
let err = CodexErr::ServerOverloaded;
@@ -177,6 +216,7 @@ fn usage_limit_reached_error_formats_free_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -191,6 +231,7 @@ fn usage_limit_reached_error_formats_go_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -205,6 +246,7 @@ fn usage_limit_reached_error_formats_default_when_none() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -223,6 +265,7 @@ fn usage_limit_reached_error_formats_team_plan() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
@@ -238,6 +281,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -252,6 +296,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -266,6 +311,7 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -280,6 +326,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -298,6 +345,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
@@ -324,6 +372,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() {
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
.to_string(),
),
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}."
@@ -343,6 +392,7 @@ fn usage_limit_reached_includes_minutes_when_available() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -482,6 +532,7 @@ fn usage_limit_reached_includes_hours_and_minutes() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
@@ -502,6 +553,7 @@ fn usage_limit_reached_includes_days_hours_minutes() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -519,6 +571,7 @@ fn usage_limit_reached_less_than_minute() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -538,6 +591,7 @@ fn usage_limit_reached_with_promo_message() {
promo_message: Some(
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
),
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
+15
View File
@@ -2000,6 +2000,21 @@ pub enum RateLimitReachedType {
WorkspaceMemberUsageLimitReached,
}
impl FromStr for RateLimitReachedType {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"rate_limit_reached" => Ok(Self::RateLimitReached),
"workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted),
"workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted),
"workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached),
"workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached),
other => Err(format!("unknown rate limit reached type: {other}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct RateLimitWindow {
/// Percentage (0-100) of the window that has been consumed.
+1
View File
@@ -31,6 +31,7 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
[lib]
@@ -0,0 +1,85 @@
{
"source": "standard/google_calendar/tools",
"tools": [
{
"name": "google_calendar_create_event",
"description": "Create a calendar event.",
"input_schema": {
"type": "object",
"properties": {
"event": {
"$ref": "#/definitions/Event"
},
"notify_attendees": {
"type": "boolean",
"description": "Whether attendees should receive notifications."
}
},
"required": [
"event"
],
"definitions": {
"Event": {
"type": "object",
"description": "Calendar event payload.",
"properties": {
"title": {
"type": "string",
"description": "Event title."
},
"start": {
"anyOf": [
{
"$ref": "#/definitions/DateTime"
},
{
"type": "null"
}
]
}
}
},
"DateTime": {
"type": "object",
"description": "Calendar date-time.",
"properties": {
"dateTime": {
"type": "string",
"format": "date-time",
"description": "RFC3339 date-time."
},
"timeZone": {
"type": "string",
"enum": [
"UTC",
"America/Los_Angeles"
],
"description": "IANA time zone."
}
}
},
"UnusedCalendarResource": {
"type": "string",
"description": "Unreachable calendar resource."
}
}
},
"expected_preserved": [
{
"pointer": "/properties/event/$ref",
"value": "#/definitions/Event"
},
{
"pointer": "/definitions/DateTime/properties/timeZone/enum/1",
"value": "America/Los_Angeles"
}
],
"expected_pruned": [
"/definitions/UnusedCalendarResource"
],
"expected_dropped_fields": [
"/definitions/DateTime/properties/dateTime/format"
]
}
]
}
@@ -0,0 +1,65 @@
{
"source": "standard/google_drive/tools",
"tools": [
{
"name": "google_drive_copy_file",
"description": "Copy a Google Drive file.",
"input_schema": {
"type": "object",
"properties": {
"file": {
"description": "File selector.",
"oneOf": [
{
"type": "string",
"description": "A Drive file ID."
},
{
"type": "object",
"properties": {
"shared_drive_id": {
"type": "string",
"description": "Shared drive identifier."
}
}
}
]
},
"metadata": {
"description": "Optional copy metadata.",
"allOf": [
{
"type": "object",
"description": "Base metadata.",
"properties": {
"source": {
"type": "string",
"description": "Metadata source."
}
}
}
]
},
"title": {
"type": "string",
"description": "Copied file title."
}
},
"required": [
"file"
]
},
"expected_preserved": [
{
"pointer": "/properties/title/description",
"value": "Copied file title."
}
],
"expected_pruned": [],
"expected_dropped_fields": [
"/properties/file/oneOf",
"/properties/metadata/allOf"
]
}
]
}
@@ -0,0 +1,90 @@
{
"source": "golden/microsoft_outlook_email/tools",
"tools": [
{
"name": "microsoft_outlook_email_send",
"description": "Send an Outlook email.",
"input_schema": {
"type": "object",
"properties": {
"message_id": {
"$ref": "#/$defs/Message/properties/id"
},
"attachment": {
"type": "array",
"items": {
"$ref": "#/$defs/Attachment"
}
}
},
"$defs": {
"Message": {
"type": "object",
"description": "Outlook message.",
"properties": {
"id": {
"type": "string",
"description": "Message identifier."
},
"sender": {
"$ref": "#/$defs/EmailAddress"
},
"importance": {
"type": "string",
"enum": [
"low",
"normal",
"high"
],
"description": "Message importance."
}
}
},
"Attachment": {
"type": "object",
"description": "Email attachment.",
"properties": {
"name": {
"type": "string",
"description": "Attachment file name."
}
}
},
"EmailAddress": {
"type": "object",
"description": "Email address object.",
"properties": {
"address": {
"type": "string",
"description": "SMTP address."
}
}
},
"GiantUnusedPayload": {
"type": "object",
"description": "Representative unreachable Outlook payload.",
"properties": {
"opaque": {
"type": "string"
}
}
}
}
},
"expected_preserved": [
{
"pointer": "/properties/message_id/$ref",
"value": "#/$defs/Message/properties/id"
},
{
"pointer": "/$defs/Message/properties/importance/enum/2",
"value": "high"
}
],
"expected_pruned": [
"/$defs/GiantUnusedPayload"
],
"expected_dropped_fields": []
}
]
}
@@ -0,0 +1,72 @@
{
"source": "golden/notion/tools",
"tools": [
{
"name": "notion_fetch_page",
"description": "Fetch a Notion page.",
"input_schema": {
"type": "object",
"properties": {
"page": {
"$ref": "#/$defs/Page%20Ref"
},
"filter": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/Filter"
}
},
"content_mode": {
"description": "How much content to return.",
"anyOf": [
{
"type": "string",
"enum": [
"summary",
"full"
]
},
{
"type": "null"
}
]
}
},
"$defs": {
"Page Ref": {
"type": "string",
"description": "Notion page ID or URL."
},
"Filter": {
"type": "object",
"description": "Filter object.",
"properties": {
"created_by": {
"type": "string",
"description": "Creator user ID."
}
}
},
"UnusedDatabase": {
"type": "object",
"description": "Unreachable Notion database schema."
}
}
},
"expected_preserved": [
{
"pointer": "/properties/page/$ref",
"value": "#/$defs/Page%20Ref"
},
{
"pointer": "/properties/content_mode/anyOf/0/enum/0",
"value": "summary"
}
],
"expected_pruned": [
"/$defs/UnusedDatabase"
],
"expected_dropped_fields": []
}
]
}
@@ -0,0 +1,75 @@
{
"source": "standard/slack/tools",
"tools": [
{
"name": "slack_schedule_message",
"description": "Schedule a Slack message.",
"input_schema": {
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "Slack channel ID."
},
"message": {
"type": "string",
"description": "Message text."
},
"post_at": {
"type": "integer",
"description": "Unix timestamp for delivery."
},
"thread_ts": {
"description": "Optional parent thread timestamp.",
"anyOf": [
{
"$ref": "#/$defs/SlackTs"
},
{
"type": "null"
}
]
}
},
"required": [
"channel_id",
"message",
"post_at"
],
"additionalProperties": false,
"$defs": {
"SlackTs": {
"type": "string",
"description": "Slack timestamp string.",
"pattern": "^[0-9]+[.][0-9]+$"
},
"UnusedPayload": {
"type": "object",
"description": "Large unreachable Slack payload.",
"properties": {
"debug": {
"type": "string"
}
}
}
}
},
"expected_preserved": [
{
"pointer": "/properties/thread_ts/anyOf/0/$ref",
"value": "#/$defs/SlackTs"
},
{
"pointer": "/$defs/SlackTs/description",
"value": "Slack timestamp string."
}
],
"expected_pruned": [
"/$defs/UnusedPayload"
],
"expected_dropped_fields": [
"/$defs/SlackTs/pattern"
]
}
]
}
@@ -0,0 +1,224 @@
use codex_tools::ToolName;
use codex_tools::mcp_tool_to_responses_api_tool;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::sync::Arc;
const FIXTURE_PATHS: [&str; 5] = [
"tests/fixtures/json_schema_policy/slack.json",
"tests/fixtures/json_schema_policy/google_calendar.json",
"tests/fixtures/json_schema_policy/google_drive.json",
"tests/fixtures/json_schema_policy/notion.json",
"tests/fixtures/json_schema_policy/microsoft_outlook_email.json",
];
const OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH: &str =
"tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json";
#[derive(Debug, Deserialize)]
struct FixtureFile {
source: String,
tools: Vec<FixtureTool>,
}
#[derive(Debug, Deserialize)]
struct FixtureTool {
name: String,
description: String,
input_schema: Value,
#[serde(default)]
expected_preserved: Vec<ExpectedValue>,
#[serde(default)]
expected_pruned: Vec<String>,
#[serde(default)]
expected_dropped_fields: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ExpectedValue {
pointer: String,
value: Value,
}
#[test]
fn json_schema_policy_fixtures_convert_to_responses_tools() {
for fixture in FIXTURE_PATHS.into_iter().map(load_fixture::<FixtureFile>) {
for fixture_tool in &fixture.tools {
let responses_tool = convert_fixture_tool(&fixture, fixture_tool);
let parameters = serde_json::to_value(&responses_tool.parameters)
.expect("responses parameters should serialize");
let expected_fields = [
(
"preserve the tool name",
json!(fixture_tool.name),
json!(responses_tool.name),
),
(
"preserve the tool description",
json!(fixture_tool.description),
json!(responses_tool.description),
),
(
"remain a strict:false tool",
json!(false),
json!(responses_tool.strict),
),
(
"produce object-shaped parameters",
json!("object"),
parameters.get("type").cloned().unwrap_or(Value::Null),
),
];
for (message, expected, actual) in expected_fields {
assert_eq!(actual, expected, "{} should {message}", fixture_tool.name);
}
assert!(
parameters.get("properties").is_some_and(Value::is_object),
"{} should produce a parameters.properties object",
fixture_tool.name
);
for expected in &fixture_tool.expected_preserved {
assert_eq!(
parameters.pointer(&expected.pointer),
Some(&expected.value),
"{} should preserve {}",
fixture_tool.name,
expected.pointer
);
}
for pointer in &fixture_tool.expected_pruned {
assert!(
parameters.pointer(pointer).is_none(),
"{} should prune unreachable definition {pointer}",
fixture_tool.name
);
}
for pointer in &fixture_tool.expected_dropped_fields {
assert!(
fixture_tool.input_schema.pointer(pointer).is_some(),
"{} fixture should contain expected dropped field {pointer}",
fixture_tool.name
);
assert!(
parameters.pointer(pointer).is_none(),
"{} should drop field {pointer} after JsonSchema conversion",
fixture_tool.name
);
}
}
}
}
#[test]
fn json_schema_policy_oversized_golden_schema_triggers_compaction() {
let fixture: FixtureFile = load_fixture(OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH);
let fixture_tool = fixture
.tools
.first()
.expect("oversized fixture should contain a tool");
let input_bytes = compact_json_len(&fixture_tool.input_schema);
let responses_tool = convert_fixture_tool(&fixture, fixture_tool);
let parameters =
serde_json::to_value(&responses_tool.parameters).expect("responses parameters serialize");
let output_bytes = compact_json_len(&parameters);
assert!(
output_bytes < input_bytes,
"compaction should reduce schema size from {input_bytes} bytes"
);
let absent_pointers = [
("/description", "drop root description"),
("/properties/parent/description", "drop nested descriptions"),
(
"/$defs",
"drop root definitions after stripping descriptions is insufficient",
),
];
for (pointer, message) in absent_pointers {
assert!(
parameters.pointer(pointer).is_none(),
"oversized schema should {message}"
);
}
let expected_values = [
(
"/properties/parent",
json!({}),
"rewrite local refs before dropping root definitions",
),
(
"/properties/children/items",
json!({}),
"rewrite nested local refs before dropping root definitions",
),
(
"/properties/markdown/type",
json!("string"),
"retain top-level argument shape",
),
(
"/properties/properties/type",
json!("object"),
"retain object argument shape",
),
];
for (pointer, expected, message) in expected_values {
assert_eq!(
parameters.pointer(pointer),
Some(&expected),
"oversized schema should {message}"
);
}
}
fn load_fixture<T: DeserializeOwned>(path: &str) -> T {
let path = codex_utils_cargo_bin::find_resource!(path)
.unwrap_or_else(|err| panic!("resolve fixture {path}: {err}"));
let fixture = fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("read fixture {}: {err}", path.display()));
serde_json::from_str(&fixture)
.unwrap_or_else(|err| panic!("parse fixture {}: {err}", path.display()))
}
fn convert_fixture_tool(
fixture: &FixtureFile,
fixture_tool: &FixtureTool,
) -> codex_tools::ResponsesApiTool {
let name = &fixture_tool.name;
let input_schema = fixture_tool
.input_schema
.as_object()
.unwrap_or_else(|| panic!("{name} input_schema should be an object"))
.clone();
let tool = rmcp::model::Tool {
name: name.to_string().into(),
title: None,
description: Some(fixture_tool.description.clone().into()),
input_schema: Arc::new(input_schema),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
};
mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool)
.unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source))
}
fn compact_json_len(value: &Value) -> usize {
serde_json::to_vec(value)
.unwrap_or_else(|err| panic!("serialize compact JSON: {err}"))
.len()
}