mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Merge 6995a8670a into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+90
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1124
File diff suppressed because it is too large
Load Diff
@@ -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(¶meters);
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user