mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
merge code-branch-20250925-045812 via /merge
This commit is contained in:
@@ -217,6 +217,7 @@ model_reasoning_summary = "detailed"
|
||||
- `CODEX_HOME`: Override config directory location
|
||||
- `OPENAI_API_KEY`: Use API key instead of ChatGPT auth
|
||||
- `OPENAI_BASE_URL`: Use alternative API endpoints
|
||||
- `OPENAI_WIRE_API`: Force the built-in OpenAI provider to use `chat` or `responses` wiring
|
||||
|
||||
 
|
||||
## FAQ
|
||||
|
||||
+27
-4
@@ -51,8 +51,15 @@ resolve_bin_path() {
|
||||
fi
|
||||
|
||||
TARGET_DIR_ABS="${target_root}"
|
||||
BIN_SUBPATH="${BIN_SUBDIR}/code"
|
||||
BIN_CARGO_FILENAME="code"
|
||||
BIN_FILENAME="code"
|
||||
if [ "$PROFILE" = "perf" ]; then
|
||||
BIN_FILENAME="code-perf"
|
||||
fi
|
||||
BIN_SUBPATH="${BIN_SUBDIR}/${BIN_FILENAME}"
|
||||
BIN_CARGO_SUBPATH="${BIN_SUBDIR}/${BIN_CARGO_FILENAME}"
|
||||
BIN_PATH="${TARGET_DIR_ABS}/${BIN_SUBPATH}"
|
||||
BIN_CARGO_PATH="${TARGET_DIR_ABS}/${BIN_CARGO_SUBPATH}"
|
||||
BIN_LINK_PATH="./target/${BIN_SUBPATH}"
|
||||
|
||||
if [ -n "${REPO_TARGET_ABS:-}" ] && [ "${TARGET_DIR_ABS}" = "${REPO_TARGET_ABS}" ]; then
|
||||
@@ -382,15 +389,31 @@ ${USE_CARGO} build ${USE_LOCKED} --profile "${PROFILE}" --bin code --bin code-tu
|
||||
if [ $? -eq 0 ]; then
|
||||
resolve_bin_path
|
||||
|
||||
if [ "$PROFILE" = "perf" ]; then
|
||||
PERF_SOURCE="${BIN_CARGO_PATH}"
|
||||
PERF_TARGET="${BIN_PATH}"
|
||||
if [ -e "${PERF_SOURCE}" ]; then
|
||||
PERF_DIR="$(dirname "${PERF_TARGET}")"
|
||||
mkdir -p "${PERF_DIR}"
|
||||
if [ -e "${PERF_TARGET}" ] || [ -L "${PERF_TARGET}" ]; then
|
||||
rm -f "${PERF_TARGET}"
|
||||
fi
|
||||
(
|
||||
cd "${PERF_DIR}" >/dev/null 2>&1
|
||||
ln -sf "$(basename "${PERF_SOURCE}")" "$(basename "${PERF_TARGET}")"
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Build successful!"
|
||||
echo "Binary location: ${BIN_DISPLAY_PATH}"
|
||||
echo ""
|
||||
|
||||
# Keep old symlink locations working for compatibility
|
||||
# Create symlink in target/release for npm wrapper expectations
|
||||
release_link_target="../${BIN_SUBDIR}/code"
|
||||
cli_link_target="../../codex-rs/target/${BIN_SUBDIR}/code"
|
||||
dev_fast_link_target="../${BIN_SUBDIR}/code"
|
||||
release_link_target="../${BIN_SUBDIR}/${BIN_FILENAME}"
|
||||
cli_link_target="../../codex-rs/target/${BIN_SUBDIR}/${BIN_FILENAME}"
|
||||
dev_fast_link_target="../${BIN_SUBDIR}/${BIN_FILENAME}"
|
||||
|
||||
if [ "${TARGET_DIR_ABS}" != "${REPO_TARGET_ABS}" ]; then
|
||||
release_link_target="${BIN_PATH}"
|
||||
|
||||
@@ -169,6 +169,11 @@ function resolveGlobalBinDir() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function looksLikeOurCodeShim(contents) {
|
||||
if (!contents) return false;
|
||||
return contents.includes('@just-every/code') || contents.includes('$(dirname "$0")/coder') || contents.includes('%~dp0coder');
|
||||
}
|
||||
|
||||
async function downloadBinary(url, dest, maxRedirects = 5, maxRetries = 3) {
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
@@ -711,8 +716,12 @@ async function main() {
|
||||
skippedCmds.push({ name: 'code', reason: `existing: ${reason}` });
|
||||
} else if (!shimLooksOurs) {
|
||||
console.error(`✓ Skipped global 'code' shim (different CLI at ${ourShim})`);
|
||||
skippedCmds.push({ name: 'code', reason: `existing: ${ourShim}` });
|
||||
const reason = conflictPaths[0] || ourShim;
|
||||
skippedCmds.push({ name: 'code', reason: `existing: ${reason}` });
|
||||
}
|
||||
} else {
|
||||
const reason = conflictPaths[0] || 'another command on PATH';
|
||||
skippedCmds.push({ name: 'code', reason: `existing: ${reason}` });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`⚠ Could not remove npm shim '${ourShim}': ${e.message}`);
|
||||
|
||||
@@ -55,6 +55,18 @@ pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_too
|
||||
|
||||
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
|
||||
|
||||
fn looks_like_bash(cmd: &str) -> bool {
|
||||
let trimmed = cmd.trim_matches('"').trim_matches('\'');
|
||||
if trimmed.eq_ignore_ascii_case("bash") || trimmed.eq_ignore_ascii_case("bash.exe") {
|
||||
return true;
|
||||
}
|
||||
Path::new(trimmed)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|name| name.eq_ignore_ascii_case("bash") || name.eq_ignore_ascii_case("bash.exe"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ApplyPatchError {
|
||||
#[error(transparent)]
|
||||
@@ -128,7 +140,7 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
||||
},
|
||||
// Bash heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
|
||||
[bash, flag, script] if bash == "bash" && flag == "-lc" => {
|
||||
[bash, flag, script] if looks_like_bash(bash) && flag == "-lc" => {
|
||||
match extract_apply_patch_from_bash(script) {
|
||||
Ok((body, workdir)) => match parse_patch(&body) {
|
||||
Ok(mut source) => {
|
||||
@@ -248,7 +260,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
||||
);
|
||||
}
|
||||
}
|
||||
[bash, flag, script] if bash == "bash" && flag == "-lc" => {
|
||||
[bash, flag, script] if looks_like_bash(bash) && flag == "-lc" => {
|
||||
if parse_patch(script).is_ok() {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(
|
||||
ApplyPatchError::ImplicitInvocation,
|
||||
@@ -886,6 +898,10 @@ mod tests {
|
||||
strs_to_strings(&["bash", "-lc", script])
|
||||
}
|
||||
|
||||
fn args_abs_bash(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["/bin/bash", "-lc", script])
|
||||
}
|
||||
|
||||
fn heredoc_script(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
|
||||
@@ -905,8 +921,7 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
||||
let args = args_bash(script);
|
||||
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir.as_deref(), expected_workdir);
|
||||
@@ -916,14 +931,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_not_match(script: &str) {
|
||||
let args = args_bash(script);
|
||||
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
||||
assert_match_args(args_bash(script), expected_workdir);
|
||||
assert_match_args(args_abs_bash(script), expected_workdir);
|
||||
}
|
||||
|
||||
fn assert_not_match_args(args: Vec<String>) {
|
||||
assert!(matches!(
|
||||
maybe_parse_apply_patch(&args),
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
));
|
||||
}
|
||||
|
||||
fn assert_not_match(script: &str) {
|
||||
assert_not_match_args(args_bash(script));
|
||||
assert_not_match_args(args_abs_bash(script));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_single_arg_is_error() {
|
||||
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
|
||||
@@ -938,12 +962,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_implicit_patch_bash_script_is_error() {
|
||||
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
|
||||
let args = args_bash(script);
|
||||
let dir = tempdir().unwrap();
|
||||
assert!(matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
));
|
||||
for args in [args_bash(script), args_abs_bash(script)] {
|
||||
assert!(matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1003,29 +1028,41 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
for args in [
|
||||
strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
PATCH"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir, None);
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
]),
|
||||
strs_to_strings(&[
|
||||
"/bin/bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
PATCH"#,
|
||||
]),
|
||||
] {
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir, None);
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ How long Codex will wait for activity on a streaming response before treating th
|
||||
|
||||
## model_provider
|
||||
|
||||
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable.
|
||||
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable and force the wire protocol (`"responses"` or `"chat"`) with `OPENAI_WIRE_API`.
|
||||
|
||||
Note that if you override `model_provider`, then you likely want to override
|
||||
`model`, as well. For example, if you are running ollama with Mistral locally,
|
||||
|
||||
@@ -72,8 +72,10 @@ struct RateLimitInfo {
|
||||
snapshot: Option<RateLimitSnapshotEvent>,
|
||||
#[serde(default)]
|
||||
observed_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, alias = "next_reset_at")]
|
||||
primary_next_reset_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
next_reset_at: Option<DateTime<Utc>>,
|
||||
secondary_next_reset_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
last_usage_limit_hit_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -141,7 +143,8 @@ pub struct StoredRateLimitSnapshot {
|
||||
pub plan: Option<String>,
|
||||
pub snapshot: Option<RateLimitSnapshotEvent>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub next_reset_at: Option<DateTime<Utc>>,
|
||||
pub primary_next_reset_at: Option<DateTime<Utc>>,
|
||||
pub secondary_next_reset_at: Option<DateTime<Utc>>,
|
||||
pub last_usage_limit_hit_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -252,6 +255,12 @@ pub fn record_rate_limit_snapshot(
|
||||
let mut info = data.rate_limit.take().unwrap_or_default();
|
||||
info.snapshot = Some(snapshot.clone());
|
||||
info.observed_at = Some(observed_at);
|
||||
info.primary_next_reset_at = snapshot
|
||||
.primary_reset_after_seconds
|
||||
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
|
||||
info.secondary_next_reset_at = snapshot
|
||||
.secondary_reset_after_seconds
|
||||
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
|
||||
data.rate_limit = Some(info);
|
||||
})
|
||||
}
|
||||
@@ -294,12 +303,17 @@ pub fn list_rate_limit_snapshots(
|
||||
Err(_) => continue,
|
||||
};
|
||||
let rate = data.rate_limit.unwrap_or_default();
|
||||
let primary_next_reset_at = rate.primary_next_reset_at;
|
||||
let secondary_next_reset_at = rate
|
||||
.secondary_next_reset_at
|
||||
.or(rate.primary_next_reset_at);
|
||||
results.push(StoredRateLimitSnapshot {
|
||||
account_id: data.account_id,
|
||||
plan: data.plan,
|
||||
snapshot: rate.snapshot,
|
||||
observed_at: rate.observed_at,
|
||||
next_reset_at: rate.next_reset_at,
|
||||
primary_next_reset_at,
|
||||
secondary_next_reset_at,
|
||||
last_usage_limit_hit_at: rate.last_usage_limit_hit_at,
|
||||
});
|
||||
}
|
||||
@@ -330,7 +344,8 @@ pub fn record_usage_limit_hint(
|
||||
info.last_usage_limit_hit_at = Some(observed_at);
|
||||
if let Some(seconds) = resets_in_seconds {
|
||||
let reset_at = observed_at + Duration::seconds(seconds as i64);
|
||||
info.next_reset_at = Some(reset_at);
|
||||
info.primary_next_reset_at = Some(reset_at);
|
||||
info.secondary_next_reset_at = Some(reset_at);
|
||||
}
|
||||
data.rate_limit = Some(info);
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ use tokio::time::timeout;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
@@ -26,6 +27,7 @@ use crate::model_family::ModelFamily;
|
||||
use crate::openai_tools::create_tools_json_for_chat_completions_api;
|
||||
use crate::util::backoff;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -38,6 +40,7 @@ pub(crate) async fn stream_chat_completions(
|
||||
client: &reqwest::Client,
|
||||
provider: &ModelProviderInfo,
|
||||
debug_logger: &Arc<Mutex<DebugLogger>>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
) -> Result<ResponseStream> {
|
||||
if prompt.output_schema.is_some() {
|
||||
return Err(CodexErr::UnsupportedOperation(
|
||||
@@ -350,7 +353,16 @@ pub(crate) async fn stream_chat_completions(
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
let req_builder = provider.create_request_builder(client, &None).await?;
|
||||
let auth = auth_manager.as_ref().and_then(|m| m.auth());
|
||||
let mut req_builder = provider.create_request_builder(client, &auth).await?;
|
||||
|
||||
if let Some(auth) = auth.as_ref() {
|
||||
if auth.mode == AuthMode::ChatGPT {
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
req_builder = req_builder.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let res = req_builder
|
||||
.header(reqwest::header::ACCEPT, "text/event-stream")
|
||||
|
||||
@@ -160,6 +160,7 @@ impl ModelClient {
|
||||
&self.client,
|
||||
&self.provider,
|
||||
&self.debug_logger,
|
||||
self.auth_manager.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -703,6 +704,10 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
|
||||
parse_header_f64(headers, "x-codex-primary-over-secondary-limit-percent")?;
|
||||
let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?;
|
||||
let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?;
|
||||
let primary_reset_after_seconds =
|
||||
parse_header_u64(headers, "x-codex-primary-reset-after-seconds");
|
||||
let secondary_reset_after_seconds =
|
||||
parse_header_u64(headers, "x-codex-secondary-reset-after-seconds");
|
||||
|
||||
Some(RateLimitSnapshotEvent {
|
||||
primary_used_percent,
|
||||
@@ -710,6 +715,8 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
|
||||
primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes,
|
||||
secondary_window_minutes,
|
||||
primary_reset_after_seconds,
|
||||
secondary_reset_after_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4754,13 +4754,7 @@ async fn try_run_turn(
|
||||
}
|
||||
ResponseEvent::RateLimits(snapshot) => {
|
||||
let mut state = sess.state.lock().unwrap();
|
||||
state.latest_rate_limits = Some(RateLimitSnapshotEvent {
|
||||
primary_used_percent: snapshot.primary_used_percent,
|
||||
secondary_used_percent: snapshot.secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent: snapshot.primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes: snapshot.primary_window_minutes,
|
||||
secondary_window_minutes: snapshot.secondary_window_minutes,
|
||||
});
|
||||
state.latest_rate_limits = Some(snapshot.clone());
|
||||
if let Some(ctx) = account_usage_context(sess) {
|
||||
let usage_home = ctx.codex_home.clone();
|
||||
let usage_account = ctx.account_id.clone();
|
||||
|
||||
@@ -14,7 +14,6 @@ use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::env::VarError;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::EnvVarError;
|
||||
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
|
||||
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
|
||||
|
||||
@@ -596,12 +596,12 @@ fn rate_limit_snapshot_to_protocol(
|
||||
let primary = codex_protocol::protocol::RateLimitWindow {
|
||||
used_percent: snapshot.primary_used_percent,
|
||||
window_minutes: Some(snapshot.primary_window_minutes),
|
||||
resets_in_seconds: None,
|
||||
resets_in_seconds: snapshot.primary_reset_after_seconds,
|
||||
};
|
||||
let secondary = codex_protocol::protocol::RateLimitWindow {
|
||||
used_percent: snapshot.secondary_used_percent,
|
||||
window_minutes: Some(snapshot.secondary_window_minutes),
|
||||
resets_in_seconds: None,
|
||||
resets_in_seconds: snapshot.secondary_reset_after_seconds,
|
||||
};
|
||||
codex_protocol::protocol::RateLimitSnapshot {
|
||||
primary: Some(primary),
|
||||
@@ -629,11 +629,19 @@ fn rate_limit_snapshot_from_protocol(
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes)
|
||||
.unwrap_or(0);
|
||||
let primary_reset_after_seconds = snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.resets_in_seconds);
|
||||
let secondary_window_minutes = snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes)
|
||||
.unwrap_or(0);
|
||||
let secondary_reset_after_seconds = snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.resets_in_seconds);
|
||||
|
||||
let ratio_percent = match (primary_window_minutes, secondary_window_minutes) {
|
||||
(0, _) | (_, 0) => f64::NAN,
|
||||
@@ -649,6 +657,8 @@ fn rate_limit_snapshot_from_protocol(
|
||||
primary_to_secondary_ratio_percent: ratio_percent,
|
||||
primary_window_minutes,
|
||||
secondary_window_minutes,
|
||||
primary_reset_after_seconds,
|
||||
secondary_reset_after_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,6 +993,12 @@ pub struct RateLimitSnapshotEvent {
|
||||
pub primary_window_minutes: u64,
|
||||
/// Rolling window duration for the secondary limit, in minutes.
|
||||
pub secondary_window_minutes: u64,
|
||||
/// Seconds until the primary window resets, if reported by the API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_reset_after_seconds: Option<u64>,
|
||||
/// Seconds until the secondary window resets, if reported by the API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secondary_reset_after_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
|
||||
@@ -31,11 +31,13 @@ pub fn assess_patch_safety(
|
||||
};
|
||||
}
|
||||
|
||||
// In Read Only mode, write operations are not permitted. Reject immediately
|
||||
// so the agent receives a clear failure without prompting for approval.
|
||||
// In Read Only mode, we need explicit user approval before writing.
|
||||
if matches!(sandbox_policy, SandboxPolicy::ReadOnly) {
|
||||
return SafetyCheck::Reject {
|
||||
reason: "write operations are disabled in read-only mode".to_string(),
|
||||
return match policy {
|
||||
AskForApproval::Never => SafetyCheck::Reject {
|
||||
reason: "write operations require approval but approval policy is set to never".to_string(),
|
||||
},
|
||||
_ => SafetyCheck::AskUser,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -316,6 +318,48 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_only_patch_requests_approval() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path().to_path_buf();
|
||||
let action = ApplyPatchAction::new_add_for_test(&cwd.join("file.txt"), "".to_string());
|
||||
|
||||
let result = assess_patch_safety(
|
||||
&action,
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
&cwd,
|
||||
);
|
||||
|
||||
assert_eq!(result, SafetyCheck::AskUser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_only_patch_rejects_when_policy_never() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path().to_path_buf();
|
||||
let action = ApplyPatchAction::new_add_for_test(&cwd.join("file.txt"), "".to_string());
|
||||
|
||||
let result = assess_patch_safety(
|
||||
&action,
|
||||
AskForApproval::Never,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
&cwd,
|
||||
);
|
||||
|
||||
match result {
|
||||
SafetyCheck::Reject { reason } => {
|
||||
assert_eq!(
|
||||
reason,
|
||||
"write operations require approval but approval policy is set to never"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
panic!("expected rejection, got {:?}", other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_escalated_privileges() {
|
||||
// Should not be a trusted command
|
||||
|
||||
+35
-5
@@ -24,6 +24,7 @@ use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::supports_keyboard_enhancement;
|
||||
use crossterm::SynchronizedUpdate; // trait for stdout().sync_update
|
||||
@@ -48,10 +49,10 @@ use tokio::sync::oneshot;
|
||||
|
||||
/// Time window for debouncing redraw requests.
|
||||
///
|
||||
/// Raising this slightly helps coalesce bursts of updates during typing and
|
||||
/// reduces render thrash, improving perceived input latency while staying
|
||||
/// comfortably under a 60 FPS refresh budget.
|
||||
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(16);
|
||||
/// Temporarily widened to ~30 FPS (33 ms) to coalesce bursts of updates while
|
||||
/// we smooth out per-frame hotspots; keeps redraws responsive without pegging
|
||||
/// the main thread.
|
||||
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(33);
|
||||
const DEFAULT_PTY_ROWS: u16 = 24;
|
||||
const DEFAULT_PTY_COLS: u16 = 80;
|
||||
|
||||
@@ -121,6 +122,11 @@ pub(crate) struct App<'a> {
|
||||
_transcript_saved_viewport: Option<Rect>,
|
||||
|
||||
enhanced_keys_supported: bool,
|
||||
/// Last key reported as a physical `Press`/`Repeat` while keyboard
|
||||
/// enhancements were unavailable. Used to drop the matching `Release` echo
|
||||
/// that some Windows terminals emit, without impacting release-only
|
||||
/// terminals whose characters arrive on key-up.
|
||||
last_physical_press_without_enhancements: Option<(KeyCode, KeyModifiers)>,
|
||||
|
||||
/// Debug flag for logging LLM requests/responses
|
||||
_debug: bool,
|
||||
@@ -363,6 +369,7 @@ impl App<'_> {
|
||||
_deferred_history_lines: Vec::new(),
|
||||
_transcript_saved_viewport: None,
|
||||
enhanced_keys_supported,
|
||||
last_physical_press_without_enhancements: None,
|
||||
_debug: debug,
|
||||
show_order_overlay,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
@@ -995,7 +1002,30 @@ impl App<'_> {
|
||||
// report only Release events. Normalize such events to Press so
|
||||
// keys register consistently.
|
||||
if !self.enhanced_keys_supported {
|
||||
key_event = KeyEvent::new(key_event.code, key_event.modifiers);
|
||||
match key_event.kind {
|
||||
KeyEventKind::Press | KeyEventKind::Repeat => {
|
||||
self.last_physical_press_without_enhancements = Some((
|
||||
key_event.code.clone(),
|
||||
key_event.modifiers,
|
||||
));
|
||||
}
|
||||
KeyEventKind::Release => {
|
||||
if let Some((code, modifiers)) =
|
||||
&self.last_physical_press_without_enhancements
|
||||
{
|
||||
if *code == key_event.code && *modifiers == key_event.modifiers
|
||||
{
|
||||
self.last_physical_press_without_enhancements = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Some terminals (notably Windows Git Bash/mintty without
|
||||
// enhanced key support) synthesize characters only on key-up
|
||||
// (e.g., Alt+NumPad) or never emit a physical press. Treat those
|
||||
// as Press so they register once.
|
||||
key_event.kind = KeyEventKind::Press;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset double‑Esc timer on any non‑Esc key
|
||||
if !matches!(key_event.code, KeyCode::Esc) {
|
||||
|
||||
+39
-181
@@ -2,7 +2,7 @@ use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::{Rc, Weak};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -123,8 +123,6 @@ use ratatui_image::picker::Picker;
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::mpsc;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
fn history_cell_logging_enabled() -> bool {
|
||||
@@ -142,7 +140,6 @@ fn history_cell_logging_enabled() -> bool {
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tracing::info;
|
||||
use fs2::FileExt;
|
||||
// use image::GenericImageView;
|
||||
|
||||
use crate::app_event::{
|
||||
@@ -274,7 +271,6 @@ struct RunningCommand {
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0];
|
||||
const RATE_LIMIT_REFRESH_INTERVAL: chrono::Duration = chrono::Duration::minutes(10);
|
||||
|
||||
const RATE_LIMIT_RESET_FILE: &str = "state/rate_limit_resets.json";
|
||||
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -379,8 +375,8 @@ pub(crate) struct ChatWidget<'a> {
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
rate_limit_fetch_inflight: bool,
|
||||
rate_limit_last_fetch_at: Option<DateTime<Utc>>,
|
||||
rate_limit_last_primary_reset_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
rate_limit_last_weekly_reset_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
rate_limit_primary_next_reset_at: Option<DateTime<Utc>>,
|
||||
rate_limit_secondary_next_reset_at: Option<DateTime<Utc>>,
|
||||
content_buffer: String,
|
||||
// Buffer for streaming assistant answer text; we do not surface partial
|
||||
// We wait for the final AgentMessage event and then emit the full text
|
||||
@@ -2540,12 +2536,6 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Initialize image protocol for rendering screenshots
|
||||
|
||||
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
|
||||
let RateLimitResetCache {
|
||||
primary_last_reset,
|
||||
weekly_last_reset,
|
||||
} = reset_cache;
|
||||
|
||||
let mut new_widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
@@ -2572,8 +2562,8 @@ impl ChatWidget<'_> {
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_fetch_inflight: false,
|
||||
rate_limit_last_fetch_at: None,
|
||||
rate_limit_last_primary_reset_at: primary_last_reset,
|
||||
rate_limit_last_weekly_reset_at: weekly_last_reset,
|
||||
rate_limit_primary_next_reset_at: None,
|
||||
rate_limit_secondary_next_reset_at: None,
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState {
|
||||
@@ -2694,6 +2684,14 @@ impl ChatWidget<'_> {
|
||||
system_cell_by_id: HashMap::new(),
|
||||
standard_terminal_mode: !config.tui.alternate_screen,
|
||||
};
|
||||
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
|
||||
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
|
||||
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
|
||||
new_widget.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
|
||||
new_widget.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed footer access indicator based on current config
|
||||
new_widget.apply_access_mode_indicator_from_config();
|
||||
// Insert the welcome cell as top-of-first-request so future model output
|
||||
@@ -2774,12 +2772,6 @@ impl ChatWidget<'_> {
|
||||
// Basic widget state mirrors `new`
|
||||
let history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
|
||||
|
||||
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
|
||||
let RateLimitResetCache {
|
||||
primary_last_reset,
|
||||
weekly_last_reset,
|
||||
} = reset_cache;
|
||||
|
||||
let mut w = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
@@ -2803,8 +2795,8 @@ impl ChatWidget<'_> {
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_fetch_inflight: false,
|
||||
rate_limit_last_fetch_at: None,
|
||||
rate_limit_last_primary_reset_at: primary_last_reset,
|
||||
rate_limit_last_weekly_reset_at: weekly_last_reset,
|
||||
rate_limit_primary_next_reset_at: None,
|
||||
rate_limit_secondary_next_reset_at: None,
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState {
|
||||
@@ -2923,6 +2915,14 @@ impl ChatWidget<'_> {
|
||||
synthetic_system_req: None,
|
||||
system_cell_by_id: HashMap::new(),
|
||||
};
|
||||
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
|
||||
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
|
||||
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
|
||||
w.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
|
||||
w.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
w.set_standard_terminal_mode(!config.tui.alternate_screen);
|
||||
if show_welcome {
|
||||
w.history_push_top_next_req(history_cell::new_animated_welcome());
|
||||
@@ -3490,17 +3490,15 @@ impl ChatWidget<'_> {
|
||||
match record {
|
||||
Some(record) => {
|
||||
if let Some(snapshot) = record.snapshot.clone() {
|
||||
let view_snapshot = RateLimitSnapshotEvent {
|
||||
primary_used_percent: snapshot.primary_used_percent,
|
||||
secondary_used_percent: snapshot.secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent:
|
||||
snapshot.primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes: snapshot.primary_window_minutes,
|
||||
secondary_window_minutes: snapshot.secondary_window_minutes,
|
||||
let view_snapshot = snapshot.clone();
|
||||
let view_reset = RateLimitResetInfo {
|
||||
primary_next_reset: record.primary_next_reset_at,
|
||||
secondary_next_reset: record.secondary_next_reset_at,
|
||||
..RateLimitResetInfo::default()
|
||||
};
|
||||
let view = build_limits_view(
|
||||
&view_snapshot,
|
||||
RateLimitResetInfo::default(),
|
||||
view_reset,
|
||||
DEFAULT_GRID_CONFIG,
|
||||
);
|
||||
let header = Self::account_header_lines(
|
||||
@@ -5871,8 +5869,7 @@ impl ChatWidget<'_> {
|
||||
self.last_token_usage = info.last_token_usage.clone();
|
||||
}
|
||||
if let Some(snapshot) = event.rate_limits {
|
||||
let previous_snapshot = self.rate_limit_snapshot.clone();
|
||||
self.update_rate_limit_resets(previous_snapshot.as_ref(), &snapshot);
|
||||
self.update_rate_limit_resets(&snapshot);
|
||||
let warnings = self
|
||||
.rate_limit_warnings
|
||||
.take_warnings(snapshot.secondary_used_percent, snapshot.primary_used_percent);
|
||||
@@ -7215,8 +7212,8 @@ impl ChatWidget<'_> {
|
||||
let context_tokens_used = context_window.map(|_| self.last_token_usage.tokens_in_context_window());
|
||||
|
||||
RateLimitResetInfo {
|
||||
primary_last_reset: self.rate_limit_last_primary_reset_at,
|
||||
weekly_last_reset: self.rate_limit_last_weekly_reset_at,
|
||||
primary_next_reset: self.rate_limit_primary_next_reset_at,
|
||||
secondary_next_reset: self.rate_limit_secondary_next_reset_at,
|
||||
session_tokens_used,
|
||||
auto_compact_limit,
|
||||
overflow_auto_compact: true,
|
||||
@@ -7225,65 +7222,14 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_rate_limit_resets(
|
||||
&mut self,
|
||||
previous: Option<&RateLimitSnapshotEvent>,
|
||||
current: &RateLimitSnapshotEvent,
|
||||
) {
|
||||
fn update_rate_limit_resets(&mut self, current: &RateLimitSnapshotEvent) {
|
||||
let now = Utc::now();
|
||||
let mut changed_primary = false;
|
||||
let mut changed_weekly = false;
|
||||
|
||||
if let Some(prev) = previous {
|
||||
if current.primary_used_percent + 1.0 < prev.primary_used_percent {
|
||||
if self
|
||||
.rate_limit_last_primary_reset_at
|
||||
.map_or(true, |existing| now > existing)
|
||||
{
|
||||
self.rate_limit_last_primary_reset_at = Some(now);
|
||||
changed_primary = true;
|
||||
}
|
||||
}
|
||||
if current.secondary_used_percent + 0.5 < prev.secondary_used_percent {
|
||||
if self
|
||||
.rate_limit_last_weekly_reset_at
|
||||
.map_or(true, |existing| now > existing)
|
||||
{
|
||||
self.rate_limit_last_weekly_reset_at = Some(now);
|
||||
changed_weekly = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if current.primary_used_percent <= 1.0
|
||||
&& self.rate_limit_last_primary_reset_at.is_none()
|
||||
{
|
||||
self.rate_limit_last_primary_reset_at = Some(now);
|
||||
changed_primary = true;
|
||||
}
|
||||
if current.secondary_used_percent <= 1.0
|
||||
&& self.rate_limit_last_weekly_reset_at.is_none()
|
||||
{
|
||||
self.rate_limit_last_weekly_reset_at = Some(now);
|
||||
changed_weekly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed_primary || changed_weekly {
|
||||
let candidate_info = self.rate_limit_reset_info();
|
||||
let candidate_cache = RateLimitResetCache {
|
||||
primary_last_reset: candidate_info.primary_last_reset,
|
||||
weekly_last_reset: candidate_info.weekly_last_reset,
|
||||
};
|
||||
match persist_rate_limit_reset_cache(&self.config.codex_home, candidate_cache) {
|
||||
Ok(merged) => {
|
||||
self.rate_limit_last_primary_reset_at = merged.primary_last_reset;
|
||||
self.rate_limit_last_weekly_reset_at = merged.weekly_last_reset;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to persist rate limit resets: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rate_limit_primary_next_reset_at = current
|
||||
.primary_reset_after_seconds
|
||||
.map(|secs| now + ChronoDuration::seconds(secs as i64));
|
||||
self.rate_limit_secondary_next_reset_at = current
|
||||
.secondary_reset_after_seconds
|
||||
.map(|secs| now + ChronoDuration::seconds(secs as i64));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_update_command(&mut self) {
|
||||
@@ -17203,94 +17149,6 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
struct RateLimitResetCache {
|
||||
#[serde(default)]
|
||||
primary_last_reset: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
weekly_last_reset: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
fn load_rate_limit_reset_cache(codex_home: &Path) -> RateLimitResetCache {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(RATE_LIMIT_RESET_FILE));
|
||||
match std::fs::read_to_string(&read_path) {
|
||||
Ok(contents) => match serde_json::from_str::<RateLimitResetCache>(&contents) {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => {
|
||||
tracing::debug!("failed to parse rate limit reset cache: {err:?}");
|
||||
RateLimitResetCache::default()
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
tracing::debug!("failed to read rate limit reset cache: {err:?}");
|
||||
}
|
||||
RateLimitResetCache::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_rate_limit_reset_cache(
|
||||
codex_home: &Path,
|
||||
candidate: RateLimitResetCache,
|
||||
) -> io::Result<RateLimitResetCache> {
|
||||
if candidate.primary_last_reset.is_none() && candidate.weekly_last_reset.is_none() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
|
||||
let path = rate_limit_reset_cache_path(codex_home);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&path)?;
|
||||
FileExt::lock_exclusive(&file)?;
|
||||
|
||||
let mut buf = String::new();
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.read_to_string(&mut buf)?;
|
||||
let mut existing: RateLimitResetCache = if buf.trim().is_empty() {
|
||||
RateLimitResetCache::default()
|
||||
} else {
|
||||
serde_json::from_str(&buf).unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
changed |= merge_timestamp(&mut existing.primary_last_reset, candidate.primary_last_reset);
|
||||
changed |= merge_timestamp(&mut existing.weekly_last_reset, candidate.weekly_last_reset);
|
||||
|
||||
if changed {
|
||||
let serialized = serde_json::to_string_pretty(&existing)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
|
||||
file.set_len(0)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.write_all(serialized.as_bytes())?;
|
||||
file.flush()?;
|
||||
}
|
||||
|
||||
let _ = FileExt::unlock(&file);
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
fn rate_limit_reset_cache_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(RATE_LIMIT_RESET_FILE)
|
||||
}
|
||||
|
||||
fn merge_timestamp(dst: &mut Option<DateTime<Utc>>, candidate: Option<DateTime<Utc>>) -> bool {
|
||||
match (dst.as_ref(), candidate) {
|
||||
(_, None) => false,
|
||||
(Some(existing), Some(candidate_dt)) if *existing >= candidate_dt => false,
|
||||
_ => {
|
||||
*dst = candidate;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Top-level widget render timing
|
||||
|
||||
@@ -5,7 +5,7 @@ use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent};
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_types::ReasoningEffort;
|
||||
use codex_core::debug_logger::DebugLogger;
|
||||
use codex_core::protocol::{Event, EventMsg, TokenCountEvent, RateLimitSnapshotEvent as CoreRateLimitSnapshotEvent};
|
||||
use codex_core::protocol::{Event, EventMsg, RateLimitSnapshotEvent, TokenCountEvent};
|
||||
use codex_protocol::models::{ContentItem, ResponseItem};
|
||||
use futures::StreamExt;
|
||||
use tokio::runtime::Runtime;
|
||||
@@ -80,13 +80,7 @@ fn run_refresh(
|
||||
|
||||
let proto_snapshot = snapshot.context("rate limit snapshot missing from response")?;
|
||||
|
||||
let snapshot = CoreRateLimitSnapshotEvent {
|
||||
primary_used_percent: proto_snapshot.primary_used_percent,
|
||||
secondary_used_percent: proto_snapshot.secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent: proto_snapshot.primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes: proto_snapshot.primary_window_minutes,
|
||||
secondary_window_minutes: proto_snapshot.secondary_window_minutes,
|
||||
};
|
||||
let snapshot: RateLimitSnapshotEvent = proto_snapshot.clone();
|
||||
|
||||
let event = Event {
|
||||
id: "rate-limit-refresh".to_string(),
|
||||
|
||||
@@ -196,7 +196,10 @@ fn limits_overlay_renders_snapshot() {
|
||||
primary_to_secondary_ratio_percent: 0.0,
|
||||
primary_window_minutes: 300,
|
||||
secondary_window_minutes: 10_080,
|
||||
primary_reset_after_seconds: Some(600),
|
||||
secondary_reset_after_seconds: Some(3_600),
|
||||
});
|
||||
chat.update_rate_limit_resets(chat.rate_limit_snapshot.as_ref().unwrap());
|
||||
chat.rate_limit_last_fetch_at = Some(Utc::now());
|
||||
|
||||
chat.add_limits_output();
|
||||
@@ -218,6 +221,11 @@ fn limits_overlay_renders_snapshot() {
|
||||
assert!(joined.contains("Weekly Limit"), "overlay text: {joined}");
|
||||
assert!(joined.contains(" Type: "));
|
||||
assert!(joined.contains(" Plan: "));
|
||||
assert!(joined.contains("Resets"), "overlay text: {joined}");
|
||||
assert!(
|
||||
!joined.contains("awaiting reset timing"),
|
||||
"expected reset timings to render: {joined}"
|
||||
);
|
||||
assert!(joined.contains(" Total: "));
|
||||
assert!(joined.contains("Chart"));
|
||||
assert!(joined.contains("7 Day History"));
|
||||
|
||||
@@ -87,8 +87,8 @@ pub(crate) struct GridConfig {
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct RateLimitResetInfo {
|
||||
pub(crate) primary_last_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) weekly_last_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) primary_next_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) secondary_next_reset: Option<DateTime<Utc>>,
|
||||
pub(crate) session_tokens_used: Option<u64>,
|
||||
pub(crate) auto_compact_limit: Option<u64>,
|
||||
pub(crate) overflow_auto_compact: bool,
|
||||
@@ -178,11 +178,11 @@ fn build_summary_lines(
|
||||
));
|
||||
lines.push(build_hourly_window_line(
|
||||
metrics,
|
||||
reset_info.primary_last_reset,
|
||||
reset_info.primary_next_reset,
|
||||
));
|
||||
lines.push(build_hourly_reset_line(
|
||||
snapshot.primary_window_minutes,
|
||||
reset_info.primary_last_reset,
|
||||
reset_info.primary_next_reset,
|
||||
));
|
||||
|
||||
lines.push("".into());
|
||||
@@ -196,11 +196,11 @@ fn build_summary_lines(
|
||||
));
|
||||
lines.push(build_weekly_window_line(
|
||||
metrics.weekly_window_minutes,
|
||||
reset_info.weekly_last_reset,
|
||||
reset_info.secondary_next_reset,
|
||||
));
|
||||
lines.push(build_weekly_reset_line(
|
||||
snapshot.secondary_window_minutes,
|
||||
reset_info.weekly_last_reset,
|
||||
reset_info.secondary_next_reset,
|
||||
));
|
||||
|
||||
lines.push("".into());
|
||||
@@ -240,7 +240,7 @@ fn build_bar_line(label: &str, percent: f64, suffix: &str, style: Style) -> Line
|
||||
|
||||
fn build_hourly_window_line(
|
||||
metrics: &RateLimitMetrics,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
let prefix = field_prefix("Window");
|
||||
if metrics.primary_window_minutes == 0 {
|
||||
@@ -253,8 +253,8 @@ fn build_hourly_window_line(
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(last) = last_reset {
|
||||
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, last) {
|
||||
if let Some(next) = next_reset {
|
||||
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, next) {
|
||||
let window_secs = timing.window.as_secs_f64();
|
||||
if window_secs > 0.0 {
|
||||
let elapsed = timing.elapsed();
|
||||
@@ -278,25 +278,25 @@ fn build_hourly_window_line(
|
||||
}
|
||||
}
|
||||
|
||||
Line::from(vec![
|
||||
Span::raw(prefix),
|
||||
Span::styled(
|
||||
format!(
|
||||
"(unknown / {})",
|
||||
format_minutes_round_units(metrics.primary_window_minutes)
|
||||
),
|
||||
Style::default().fg(colors::dim()),
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(prefix));
|
||||
spans.push(Span::styled(
|
||||
format!(
|
||||
"(unknown / {})",
|
||||
format_minutes_round_units(metrics.primary_window_minutes)
|
||||
),
|
||||
])
|
||||
Style::default().fg(colors::dim()),
|
||||
));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn build_hourly_reset_line(
|
||||
window_minutes: u64,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
if let (Some(last), true) = (last_reset, window_minutes > 0) {
|
||||
if let (Some(next), true) = (next_reset, window_minutes > 0) {
|
||||
let prefix = field_prefix("Resets");
|
||||
if let Some(timing) = compute_window_timing(window_minutes, last) {
|
||||
if let Some(timing) = compute_window_timing(window_minutes, next) {
|
||||
let remaining = format_duration(timing.remaining);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(prefix.clone()));
|
||||
@@ -320,7 +320,7 @@ fn build_hourly_reset_line(
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(field_prefix("Resets")));
|
||||
spans.push(Span::styled(
|
||||
"shown once next window detected".to_string(),
|
||||
"awaiting reset timing…".to_string(),
|
||||
Style::default().fg(colors::dim()),
|
||||
));
|
||||
Line::from(spans)
|
||||
@@ -328,7 +328,7 @@ fn build_hourly_reset_line(
|
||||
|
||||
fn build_weekly_window_line(
|
||||
weekly_minutes: u64,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
let prefix = field_prefix("Window");
|
||||
if weekly_minutes == 0 {
|
||||
@@ -341,8 +341,8 @@ fn build_weekly_window_line(
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(last) = last_reset {
|
||||
if let Some(timing) = compute_window_timing(weekly_minutes, last) {
|
||||
if let Some(next) = next_reset {
|
||||
if let Some(timing) = compute_window_timing(weekly_minutes, next) {
|
||||
let window_secs = timing.window.as_secs_f64();
|
||||
if window_secs > 0.0 {
|
||||
let elapsed = timing.elapsed();
|
||||
@@ -379,11 +379,11 @@ fn build_weekly_window_line(
|
||||
|
||||
fn build_weekly_reset_line(
|
||||
window_minutes: u64,
|
||||
last_reset: Option<DateTime<Utc>>,
|
||||
next_reset: Option<DateTime<Utc>>,
|
||||
) -> Line<'static> {
|
||||
if let (Some(last), true) = (last_reset, window_minutes > 0) {
|
||||
if let (Some(next), true) = (next_reset, window_minutes > 0) {
|
||||
let prefix = field_prefix("Resets");
|
||||
if let Some(timing) = compute_window_timing(window_minutes, last) {
|
||||
if let Some(timing) = compute_window_timing(window_minutes, next) {
|
||||
let remaining = format_duration(timing.remaining);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(prefix.clone()));
|
||||
@@ -406,7 +406,7 @@ fn build_weekly_reset_line(
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(field_prefix("Resets")));
|
||||
spans.push(Span::styled(
|
||||
"shown once next window detected".to_string(),
|
||||
"awaiting reset timing…".to_string(),
|
||||
Style::default().fg(colors::dim()),
|
||||
));
|
||||
Line::from(spans)
|
||||
@@ -609,7 +609,7 @@ impl WindowTiming {
|
||||
|
||||
fn compute_window_timing(
|
||||
window_minutes: u64,
|
||||
last_reset: DateTime<Utc>,
|
||||
next_reset: DateTime<Utc>,
|
||||
) -> Option<WindowTiming> {
|
||||
let window_seconds = (window_minutes as i64).checked_mul(60)?;
|
||||
if window_seconds <= 0 {
|
||||
@@ -617,17 +617,16 @@ fn compute_window_timing(
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let elapsed_secs = now.signed_duration_since(last_reset).num_seconds();
|
||||
let elapsed_within_window = if elapsed_secs <= 0 {
|
||||
0
|
||||
} else {
|
||||
elapsed_secs.rem_euclid(window_seconds)
|
||||
};
|
||||
let remaining_secs = (window_seconds - elapsed_within_window).max(0);
|
||||
let mut remaining_secs = next_reset.signed_duration_since(now).num_seconds();
|
||||
if remaining_secs < 0 {
|
||||
remaining_secs = 0;
|
||||
}
|
||||
if remaining_secs > window_seconds {
|
||||
remaining_secs = window_seconds;
|
||||
}
|
||||
|
||||
let window = Duration::from_secs(window_seconds as u64);
|
||||
let remaining = Duration::from_secs(remaining_secs as u64);
|
||||
let next_reset = now + chrono::Duration::seconds(remaining_secs);
|
||||
Some(WindowTiming {
|
||||
remaining,
|
||||
window,
|
||||
@@ -1020,6 +1019,8 @@ mod tests {
|
||||
primary_to_secondary_ratio_percent: 40.0,
|
||||
primary_window_minutes: 300,
|
||||
secondary_window_minutes: 10_080,
|
||||
primary_reset_after_seconds: None,
|
||||
secondary_reset_after_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ How long Codex will wait for activity on a streaming response before treating th
|
||||
|
||||
## model_provider
|
||||
|
||||
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable.
|
||||
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable and force the wire protocol (`"responses"` or `"chat"`) with `OPENAI_WIRE_API`.
|
||||
|
||||
Note that if you override `model_provider`, then you likely want to override
|
||||
`model`, as well. For example, if you are running ollama with Mistral locally,
|
||||
|
||||
Reference in New Issue
Block a user