mirror of
https://github.com/openai/codex.git
synced 2026-04-19 22:11:52 +03:00
Compare commits
4 Commits
jif/state7
...
pr4158
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98df710750 | ||
|
|
639a6fd2f3 | ||
|
|
db4aa6f916 | ||
|
|
cb96f4f596 |
6
.github/workflows/rust-ci.yml
vendored
6
.github/workflows/rust-ci.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
working-directory: codex-rs
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.89
|
||||
- uses: dtolnay/rust-toolchain@1.90
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: cargo fmt
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
working-directory: codex-rs
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.89
|
||||
- uses: dtolnay/rust-toolchain@1.90
|
||||
- uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
|
||||
with:
|
||||
tool: cargo-shear
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.89
|
||||
- uses: dtolnay/rust-toolchain@1.90
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
components: clippy
|
||||
|
||||
58
.github/workflows/rust-release.yml
vendored
58
.github/workflows/rust-release.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.89
|
||||
- uses: dtolnay/rust-toolchain@1.90
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -175,6 +175,7 @@ jobs:
|
||||
tag: ${{ github.ref_name }}
|
||||
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
|
||||
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
|
||||
slice_tags: ${{ steps.slice_tags.outputs.value }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -214,6 +215,25 @@ jobs:
|
||||
echo "npm_tag=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Determine slice tags
|
||||
id: slice_tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
slice_tags=$(python - <<'PY'
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
module_path = Path("codex-cli/scripts/build_npm_package.py").resolve()
|
||||
sys.path.insert(0, str(module_path.parent))
|
||||
spec = importlib.util.spec_from_file_location("build_npm_package", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
print(" ".join(module.DEFAULT_SLICE_TAGS), end="")
|
||||
PY
|
||||
)
|
||||
echo "value=${slice_tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# build_npm_package.py requires DotSlash when staging releases.
|
||||
- uses: facebook/install-dotslash@v2
|
||||
- name: Stage npm package
|
||||
@@ -222,10 +242,13 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMP_DIR="${RUNNER_TEMP}/npm-stage"
|
||||
OUTPUT_DIR="${GITHUB_WORKSPACE}/dist/npm"
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
./codex-cli/scripts/build_npm_package.py \
|
||||
--release-version "${{ steps.release_name.outputs.name }}" \
|
||||
--staging-dir "${TMP_DIR}" \
|
||||
--pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
|
||||
--pack-output "${OUTPUT_DIR}/codex-npm-${{ steps.release_name.outputs.name }}.tgz" \
|
||||
--slice-pack-dir "${OUTPUT_DIR}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -279,7 +302,7 @@ jobs:
|
||||
mkdir -p dist/npm
|
||||
gh release download "$tag" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "codex-npm-${version}.tgz" \
|
||||
--pattern "codex-npm-${version}*.tgz" \
|
||||
--dir dist/npm
|
||||
|
||||
# No NODE_AUTH_TOKEN needed because we use OIDC.
|
||||
@@ -296,6 +319,35 @@ jobs:
|
||||
|
||||
npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}.tgz" "${tag_args[@]}"
|
||||
|
||||
- name: Publish slice npm packages
|
||||
env:
|
||||
VERSION: ${{ needs.release.outputs.version }}
|
||||
NPM_TAG: ${{ needs.release.outputs.npm_tag }}
|
||||
SLICE_TAGS: ${{ needs.release.outputs.slice_tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${SLICE_TAGS}" ]]; then
|
||||
echo "No slice tags defined; skipping slice publish." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=' ' read -r -a slice_tags <<<"${SLICE_TAGS}"
|
||||
for slice_tag in "${slice_tags[@]}"; do
|
||||
tarball="${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}-${slice_tag}.tgz"
|
||||
if [[ ! -f "${tarball}" ]]; then
|
||||
echo "Missing slice tarball ${tarball}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_tag="${slice_tag}"
|
||||
if [[ -n "${NPM_TAG}" ]]; then
|
||||
publish_tag="${NPM_TAG}-${slice_tag}"
|
||||
fi
|
||||
|
||||
echo "Publishing ${tarball} with npm tag '${publish_tag}'"
|
||||
npm publish "${tarball}" --tag "${publish_tag}"
|
||||
done
|
||||
|
||||
update-branch:
|
||||
name: Update latest-alpha-cli branch
|
||||
permissions:
|
||||
|
||||
@@ -8,4 +8,13 @@ To build the 0.2.x or later version of the npm module, which runs the Rust versi
|
||||
./codex-cli/scripts/build_npm_package.py --release-version 0.6.0
|
||||
```
|
||||
|
||||
Note this will create `./codex-cli/vendor/` as a side-effect.
|
||||
To produce per-platform "slice" tarballs in addition to the fat package, supply the
|
||||
`--slice-pack-dir` flag to write the outputs. For example:
|
||||
|
||||
```bash
|
||||
./codex-cli/scripts/build_npm_package.py --release-version 0.6.0 --slice-pack-dir dist/npm
|
||||
```
|
||||
|
||||
The command above writes the full tarball plus the per-platform archives named with the
|
||||
VS Code-style identifiers (for example, `codex-npm-0.6.0-darwin-arm64.tgz`). Note this will
|
||||
create `./codex-cli/vendor/` as a side-effect.
|
||||
|
||||
@@ -9,12 +9,35 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from install_native_deps import CODEX_TARGETS, VENDOR_DIR_NAME
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CODEX_CLI_ROOT = SCRIPT_DIR.parent
|
||||
REPO_ROOT = CODEX_CLI_ROOT.parent
|
||||
GITHUB_REPO = "openai/codex"
|
||||
|
||||
TARGET_TO_SLICE_TAG = {
|
||||
"x86_64-unknown-linux-musl": "linux-x64",
|
||||
"aarch64-unknown-linux-musl": "linux-arm64",
|
||||
"x86_64-apple-darwin": "darwin-x64",
|
||||
"aarch64-apple-darwin": "darwin-arm64",
|
||||
"x86_64-pc-windows-msvc": "win32-x64",
|
||||
"aarch64-pc-windows-msvc": "win32-arm64",
|
||||
}
|
||||
|
||||
_SLICE_ACCUMULATOR: dict[str, list[str]] = {}
|
||||
for target in CODEX_TARGETS:
|
||||
slice_tag = TARGET_TO_SLICE_TAG.get(target)
|
||||
if slice_tag is None:
|
||||
raise RuntimeError(f"Missing slice tag mapping for target '{target}'.")
|
||||
_SLICE_ACCUMULATOR.setdefault(slice_tag, []).append(target)
|
||||
|
||||
SLICE_TAG_TO_TARGETS = {tag: tuple(targets) for tag, targets in _SLICE_ACCUMULATOR.items()}
|
||||
|
||||
DEFAULT_SLICE_TAGS = tuple(SLICE_TAG_TO_TARGETS)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.")
|
||||
@@ -52,12 +75,29 @@ def parse_args() -> argparse.Namespace:
|
||||
type=Path,
|
||||
help="Path where the generated npm tarball should be written.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slice-pack-dir",
|
||||
type=Path,
|
||||
help=(
|
||||
"Directory where per-platform slice npm tarballs should be written. "
|
||||
"When provided, all known slices are packed unless --slices is given."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slices",
|
||||
nargs="+",
|
||||
choices=sorted(DEFAULT_SLICE_TAGS),
|
||||
help="Optional subset of slice tags to pack.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
if args.slices and args.slice_pack_dir is None:
|
||||
raise RuntimeError("--slice-pack-dir is required when specifying --slices.")
|
||||
|
||||
version = args.version
|
||||
release_version = args.release_version
|
||||
if release_version:
|
||||
@@ -97,6 +137,16 @@ def main() -> int:
|
||||
|
||||
install_native_binaries(staging_dir, workflow_url)
|
||||
|
||||
slice_outputs: list[tuple[str, Path]] = []
|
||||
if args.slice_pack_dir is not None:
|
||||
slice_tags = tuple(args.slices or DEFAULT_SLICE_TAGS)
|
||||
slice_outputs = build_slice_packages(
|
||||
staging_dir,
|
||||
version,
|
||||
args.slice_pack_dir,
|
||||
slice_tags,
|
||||
)
|
||||
|
||||
if release_version:
|
||||
staging_dir_str = str(staging_dir)
|
||||
print(
|
||||
@@ -111,6 +161,9 @@ def main() -> int:
|
||||
if args.pack_output is not None:
|
||||
output_path = run_npm_pack(staging_dir, args.pack_output)
|
||||
print(f"npm pack output written to {output_path}")
|
||||
|
||||
for slice_tag, output_path in slice_outputs:
|
||||
print(f"built slice {slice_tag} tarball at {output_path}")
|
||||
finally:
|
||||
if created_temp:
|
||||
# Preserve the staging directory for further inspection.
|
||||
@@ -161,6 +214,63 @@ def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None
|
||||
subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT)
|
||||
|
||||
|
||||
def build_slice_packages(
|
||||
base_staging_dir: Path,
|
||||
version: str,
|
||||
output_dir: Path,
|
||||
slice_tags: Sequence[str],
|
||||
) -> list[tuple[str, Path]]:
|
||||
if not slice_tags:
|
||||
return []
|
||||
|
||||
base_vendor = base_staging_dir / VENDOR_DIR_NAME
|
||||
if not base_vendor.exists():
|
||||
raise RuntimeError(
|
||||
f"Base staging directory {base_staging_dir} does not include native vendor binaries."
|
||||
)
|
||||
|
||||
output_dir = output_dir.resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results: list[tuple[str, Path]] = []
|
||||
for slice_tag in slice_tags:
|
||||
targets = SLICE_TAG_TO_TARGETS.get(slice_tag)
|
||||
if not targets:
|
||||
raise RuntimeError(f"Unknown slice tag '{slice_tag}'.")
|
||||
|
||||
missing = [target for target in targets if not (base_vendor / target).exists()]
|
||||
if missing:
|
||||
missing_label = ", ".join(missing)
|
||||
raise RuntimeError(
|
||||
f"Missing native binaries for slice '{slice_tag}': {missing_label} is absent in vendor."
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"codex-npm-slice-{slice_tag}-") as slice_dir_str:
|
||||
slice_dir = Path(slice_dir_str)
|
||||
stage_sources(slice_dir, version)
|
||||
slice_vendor = slice_dir / VENDOR_DIR_NAME
|
||||
copy_vendor_slice(base_vendor, slice_vendor, targets)
|
||||
output_path = output_dir / f"codex-npm-{version}-{slice_tag}.tgz"
|
||||
run_npm_pack(slice_dir, output_path)
|
||||
results.append((slice_tag, output_path))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def copy_vendor_slice(base_vendor: Path, dest_vendor: Path, targets: Sequence[str]) -> None:
|
||||
dest_vendor.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_vendor.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for entry in base_vendor.iterdir():
|
||||
if entry.is_file():
|
||||
shutil.copy2(entry, dest_vendor / entry.name)
|
||||
|
||||
for target in targets:
|
||||
src = base_vendor / target
|
||||
dest = dest_vendor / target
|
||||
shutil.copytree(src, dest)
|
||||
|
||||
|
||||
def resolve_latest_alpha_workflow_url() -> str:
|
||||
version = determine_latest_alpha_version()
|
||||
workflow_url = fetch_workflow_url_for_version(version)
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::model_provider_info::WireApi;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::openai_tools::create_tools_json_for_responses_api;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::token_data::PlanType;
|
||||
use crate::util::backoff;
|
||||
@@ -414,9 +415,6 @@ struct SseEvent {
|
||||
delta: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseCreated {}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseCompleted {
|
||||
id: String,
|
||||
@@ -488,19 +486,39 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
|
||||
}
|
||||
|
||||
fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
||||
let primary_used_percent = parse_header_f64(headers, "x-codex-primary-used-percent")?;
|
||||
let secondary_used_percent = parse_header_f64(headers, "x-codex-secondary-used-percent")?;
|
||||
let primary_to_secondary_ratio_percent =
|
||||
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 = parse_rate_limit_window(
|
||||
headers,
|
||||
"x-codex-primary-used-percent",
|
||||
"x-codex-primary-window-minutes",
|
||||
"x-codex-primary-reset-after-seconds",
|
||||
);
|
||||
|
||||
Some(RateLimitSnapshot {
|
||||
primary_used_percent,
|
||||
secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent,
|
||||
primary_window_minutes,
|
||||
secondary_window_minutes,
|
||||
let secondary = parse_rate_limit_window(
|
||||
headers,
|
||||
"x-codex-secondary-used-percent",
|
||||
"x-codex-secondary-window-minutes",
|
||||
"x-codex-secondary-reset-after-seconds",
|
||||
);
|
||||
|
||||
if primary.is_none() && secondary.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(RateLimitSnapshot { primary, secondary })
|
||||
}
|
||||
|
||||
fn parse_rate_limit_window(
|
||||
headers: &HeaderMap,
|
||||
used_percent_header: &str,
|
||||
window_minutes_header: &str,
|
||||
resets_header: &str,
|
||||
) -> Option<RateLimitWindow> {
|
||||
let used_percent = parse_header_f64(headers, used_percent_header)?;
|
||||
|
||||
Some(RateLimitWindow {
|
||||
used_percent,
|
||||
window_minutes: parse_header_u64(headers, window_minutes_header),
|
||||
resets_in_seconds: parse_header_u64(headers, resets_header),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -267,14 +267,20 @@ pub fn get_error_message_ui(e: &CodexErr) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
|
||||
fn rate_limit_snapshot() -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
primary_used_percent: 0.5,
|
||||
secondary_used_percent: 0.3,
|
||||
primary_to_secondary_ratio_percent: 0.7,
|
||||
primary_window_minutes: 60,
|
||||
secondary_window_minutes: 120,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 50.0,
|
||||
window_minutes: Some(60),
|
||||
resets_in_seconds: Some(3600),
|
||||
}),
|
||||
secondary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(120),
|
||||
resets_in_seconds: Some(7200),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ use crate::model_family::ModelFamily;
|
||||
/// Though this would help present more accurate pricing information in the UI.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ModelInfo {
|
||||
/// Size of the context window in tokens.
|
||||
/// Size of the context window in tokens. This is the maximum size of the input context.
|
||||
pub(crate) context_window: u64,
|
||||
|
||||
/// Maximum number of output tokens that can be generated for the model.
|
||||
pub(crate) max_output_tokens: u64,
|
||||
|
||||
/// Token threshold where we should automatically compact conversation history.
|
||||
/// Token threshold where we should automatically compact conversation history. This considers
|
||||
/// input tokens + output tokens of this turn.
|
||||
pub(crate) auto_compact_token_limit: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
|
||||
_ if slug.starts_with("gpt-5-codex") => Some(ModelInfo {
|
||||
context_window: 272_000,
|
||||
max_output_tokens: 128_000,
|
||||
auto_compact_token_limit: Some(250_000),
|
||||
auto_compact_token_limit: Some(350_000),
|
||||
}),
|
||||
|
||||
_ if slug.starts_with("gpt-5") => Some(ModelInfo::new(272_000, 128_000)),
|
||||
|
||||
@@ -7,8 +7,6 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::FormatItem;
|
||||
@@ -28,7 +26,6 @@ use super::policy::is_persisted_response_item;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::ORIGINATOR;
|
||||
use crate::git_info::collect_git_info;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
@@ -36,19 +33,6 @@ use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct SessionStateSnapshot {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct SavedSession {
|
||||
pub session: SessionMeta,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ResponseItem>,
|
||||
#[serde(default)]
|
||||
pub state: SessionStateSnapshot,
|
||||
pub session_id: ConversationId,
|
||||
}
|
||||
|
||||
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
|
||||
/// every update.
|
||||
///
|
||||
|
||||
@@ -10,11 +10,6 @@ use crate::openai_tools::ResponsesApiTool;
|
||||
|
||||
const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark");
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct ApplyPatchToolArgs {
|
||||
pub(crate) input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApplyPatchToolType {
|
||||
|
||||
@@ -763,9 +763,10 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.insert_header("x-codex-primary-used-percent", "12.5")
|
||||
.insert_header("x-codex-secondary-used-percent", "40.0")
|
||||
.insert_header("x-codex-primary-over-secondary-limit-percent", "75.0")
|
||||
.insert_header("x-codex-primary-window-minutes", "10")
|
||||
.insert_header("x-codex-secondary-window-minutes", "60")
|
||||
.insert_header("x-codex-primary-reset-after-seconds", "1800")
|
||||
.insert_header("x-codex-secondary-reset-after-seconds", "7200")
|
||||
.set_body_raw(sse_body, "text/event-stream");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
@@ -811,11 +812,16 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
json!({
|
||||
"info": null,
|
||||
"rate_limits": {
|
||||
"primary_used_percent": 12.5,
|
||||
"secondary_used_percent": 40.0,
|
||||
"primary_to_secondary_ratio_percent": 75.0,
|
||||
"primary_window_minutes": 10,
|
||||
"secondary_window_minutes": 60
|
||||
"primary": {
|
||||
"used_percent": 12.5,
|
||||
"window_minutes": 10,
|
||||
"resets_in_seconds": 1800
|
||||
},
|
||||
"secondary": {
|
||||
"used_percent": 40.0,
|
||||
"window_minutes": 60,
|
||||
"resets_in_seconds": 7200
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -853,11 +859,16 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
"model_context_window": 272000
|
||||
},
|
||||
"rate_limits": {
|
||||
"primary_used_percent": 12.5,
|
||||
"secondary_used_percent": 40.0,
|
||||
"primary_to_secondary_ratio_percent": 75.0,
|
||||
"primary_window_minutes": 10,
|
||||
"secondary_window_minutes": 60
|
||||
"primary": {
|
||||
"used_percent": 12.5,
|
||||
"window_minutes": 10,
|
||||
"resets_in_seconds": 1800
|
||||
},
|
||||
"secondary": {
|
||||
"used_percent": 40.0,
|
||||
"window_minutes": 60,
|
||||
"resets_in_seconds": 7200
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -868,7 +879,20 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
let final_snapshot = final_payload
|
||||
.rate_limits
|
||||
.expect("latest rate limit snapshot should be retained");
|
||||
assert_eq!(final_snapshot.primary_used_percent, 12.5);
|
||||
assert_eq!(
|
||||
final_snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
Some(12.5)
|
||||
);
|
||||
assert_eq!(
|
||||
final_snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.resets_in_seconds),
|
||||
Some(1800)
|
||||
);
|
||||
|
||||
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await;
|
||||
}
|
||||
@@ -904,11 +928,16 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
let codex = codex_fixture.codex.clone();
|
||||
|
||||
let expected_limits = json!({
|
||||
"primary_used_percent": 100.0,
|
||||
"secondary_used_percent": 87.5,
|
||||
"primary_to_secondary_ratio_percent": 95.0,
|
||||
"primary_window_minutes": 15,
|
||||
"secondary_window_minutes": 60
|
||||
"primary": {
|
||||
"used_percent": 100.0,
|
||||
"window_minutes": 15,
|
||||
"resets_in_seconds": null
|
||||
},
|
||||
"secondary": {
|
||||
"used_percent": 87.5,
|
||||
"window_minutes": 60,
|
||||
"resets_in_seconds": null
|
||||
}
|
||||
});
|
||||
|
||||
let submission_id = codex
|
||||
|
||||
@@ -597,16 +597,18 @@ pub struct TokenCountEvent {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct RateLimitSnapshot {
|
||||
/// Percentage (0-100) of the primary window that has been consumed.
|
||||
pub primary_used_percent: f64,
|
||||
/// Percentage (0-100) of the secondary window that has been consumed.
|
||||
pub secondary_used_percent: f64,
|
||||
/// Size of the primary window relative to secondary (0-100).
|
||||
pub primary_to_secondary_ratio_percent: f64,
|
||||
/// Rolling window duration for the primary limit, in minutes.
|
||||
pub primary_window_minutes: u64,
|
||||
/// Rolling window duration for the secondary limit, in minutes.
|
||||
pub secondary_window_minutes: u64,
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct RateLimitWindow {
|
||||
/// Percentage (0-100) of the window that has been consumed.
|
||||
pub used_percent: f64,
|
||||
/// Rolling window duration, in minutes.
|
||||
pub window_minutes: Option<u64>,
|
||||
/// Seconds until the window resets.
|
||||
pub resets_in_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
// Includes prompts, tools and space to call compact.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.89.0"
|
||||
components = [ "clippy", "rustfmt", "rust-src"]
|
||||
channel = "1.90.0"
|
||||
components = ["clippy", "rustfmt", "rust-src"]
|
||||
|
||||
@@ -77,6 +77,7 @@ use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::ExecCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::history_cell::RateLimitSnapshotDisplay;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_formatting::truncate_text;
|
||||
@@ -94,6 +95,7 @@ use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Local;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_common::approval_presets::builtin_approval_presets;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
@@ -129,39 +131,46 @@ struct RateLimitWarningState {
|
||||
impl RateLimitWarningState {
|
||||
fn take_warnings(
|
||||
&mut self,
|
||||
secondary_used_percent: f64,
|
||||
primary_used_percent: f64,
|
||||
secondary_used_percent: Option<f64>,
|
||||
primary_used_percent: Option<f64>,
|
||||
) -> Vec<String> {
|
||||
if secondary_used_percent == 100.0 || primary_used_percent == 100.0 {
|
||||
let reached_secondary_cap =
|
||||
matches!(secondary_used_percent, Some(percent) if percent == 100.0);
|
||||
let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0);
|
||||
if reached_secondary_cap || reached_primary_cap {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let mut highest_secondary: Option<f64> = None;
|
||||
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
|
||||
{
|
||||
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
|
||||
self.secondary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_secondary {
|
||||
warnings.push(format!(
|
||||
"Heads up, you've used over {threshold:.0}% of your weekly limit. Run /status for a breakdown."
|
||||
));
|
||||
if let Some(secondary_used_percent) = secondary_used_percent {
|
||||
let mut highest_secondary: Option<f64> = None;
|
||||
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
|
||||
{
|
||||
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
|
||||
self.secondary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_secondary {
|
||||
warnings.push(format!(
|
||||
"Heads up, you've used over {threshold:.0}% of your weekly limit. Run /status for a breakdown."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut highest_primary: Option<f64> = None;
|
||||
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
|
||||
{
|
||||
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
|
||||
self.primary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_primary {
|
||||
warnings.push(format!(
|
||||
"Heads up, you've used over {threshold:.0}% of your 5h limit. Run /status for a breakdown."
|
||||
));
|
||||
if let Some(primary_used_percent) = primary_used_percent {
|
||||
let mut highest_primary: Option<f64> = None;
|
||||
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
|
||||
{
|
||||
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
|
||||
self.primary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_primary {
|
||||
warnings.push(format!(
|
||||
"Heads up, you've used over {threshold:.0}% of your 5h limit. Run /status for a breakdown."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
warnings
|
||||
@@ -189,7 +198,7 @@ pub(crate) struct ChatWidget {
|
||||
session_header: SessionHeader,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshot>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshotDisplay>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
// Stream lifecycle controller
|
||||
stream_controller: Option<StreamController>,
|
||||
@@ -366,16 +375,24 @@ impl ChatWidget {
|
||||
fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
if let Some(snapshot) = snapshot {
|
||||
let warnings = self.rate_limit_warnings.take_warnings(
|
||||
snapshot.secondary_used_percent,
|
||||
snapshot.primary_used_percent,
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
snapshot.primary.as_ref().map(|window| window.used_percent),
|
||||
);
|
||||
self.rate_limit_snapshot = Some(snapshot);
|
||||
|
||||
let display = history_cell::rate_limit_snapshot_display(&snapshot, Local::now());
|
||||
self.rate_limit_snapshot = Some(display);
|
||||
|
||||
if !warnings.is_empty() {
|
||||
for warning in warnings {
|
||||
self.add_to_history(history_cell::new_warning_event(warning));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
self.rate_limit_snapshot = None;
|
||||
}
|
||||
}
|
||||
/// Finalize any active exec as failed and stop/clear running UI state.
|
||||
|
||||
@@ -384,12 +384,12 @@ fn rate_limit_warnings_emit_thresholds() {
|
||||
let mut state = RateLimitWarningState::default();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
warnings.extend(state.take_warnings(10.0, 55.0));
|
||||
warnings.extend(state.take_warnings(55.0, 10.0));
|
||||
warnings.extend(state.take_warnings(10.0, 80.0));
|
||||
warnings.extend(state.take_warnings(80.0, 10.0));
|
||||
warnings.extend(state.take_warnings(10.0, 95.0));
|
||||
warnings.extend(state.take_warnings(95.0, 10.0));
|
||||
warnings.extend(state.take_warnings(Some(10.0), Some(55.0)));
|
||||
warnings.extend(state.take_warnings(Some(55.0), Some(10.0)));
|
||||
warnings.extend(state.take_warnings(Some(10.0), Some(80.0)));
|
||||
warnings.extend(state.take_warnings(Some(80.0), Some(10.0)));
|
||||
warnings.extend(state.take_warnings(Some(10.0), Some(95.0)));
|
||||
warnings.extend(state.take_warnings(Some(95.0), Some(10.0)));
|
||||
|
||||
assert_eq!(
|
||||
warnings,
|
||||
|
||||
@@ -11,6 +11,9 @@ use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use base64::Engine;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Local;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_common::create_config_summary_entries;
|
||||
use codex_common::elapsed::format_duration;
|
||||
@@ -25,6 +28,7 @@ use codex_core::project_doc::discover_project_doc_paths;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -47,6 +51,7 @@ use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -1078,11 +1083,54 @@ pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RateLimitWindowDisplay {
|
||||
pub used_percent: f64,
|
||||
pub resets_at: Option<String>,
|
||||
}
|
||||
|
||||
impl RateLimitWindowDisplay {
|
||||
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
|
||||
let resets_at = window
|
||||
.resets_in_seconds
|
||||
.and_then(|seconds| i64::try_from(seconds).ok())
|
||||
.and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs)))
|
||||
.map(|dt| dt.format("%b %-d, %Y %-I:%M %p").to_string());
|
||||
|
||||
Self {
|
||||
used_percent: window.used_percent,
|
||||
resets_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RateLimitSnapshotDisplay {
|
||||
pub primary: Option<RateLimitWindowDisplay>,
|
||||
pub secondary: Option<RateLimitWindowDisplay>,
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limit_snapshot_display(
|
||||
snapshot: &RateLimitSnapshot,
|
||||
captured_at: DateTime<Local>,
|
||||
) -> RateLimitSnapshotDisplay {
|
||||
RateLimitSnapshotDisplay {
|
||||
primary: snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
||||
secondary: snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_status_output(
|
||||
config: &Config,
|
||||
usage: &TokenUsage,
|
||||
session_id: &Option<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshot>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push("/status".magenta().into());
|
||||
@@ -1611,23 +1659,39 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
invocation_spans.into()
|
||||
}
|
||||
|
||||
fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshot>) -> Vec<Line<'static>> {
|
||||
fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotDisplay>) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![vec![padded_emoji("⏱️").into(), "Usage Limits".bold()].into()];
|
||||
|
||||
match snapshot {
|
||||
Some(snapshot) => {
|
||||
let rows = [
|
||||
("5h limit".to_string(), snapshot.primary_used_percent),
|
||||
("Weekly limit".to_string(), snapshot.secondary_used_percent),
|
||||
];
|
||||
let label_width = rows
|
||||
.iter()
|
||||
.map(|(label, _)| UnicodeWidthStr::width(label.as_str()))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
for (label, percent) in rows {
|
||||
lines.push(build_status_limit_line(&label, percent, label_width));
|
||||
let mut windows: Vec<(&str, &RateLimitWindowDisplay)> = Vec::new();
|
||||
if let Some(primary) = snapshot.primary.as_ref() {
|
||||
windows.push(("5h limit", primary));
|
||||
}
|
||||
if let Some(secondary) = snapshot.secondary.as_ref() {
|
||||
windows.push(("Weekly limit", secondary));
|
||||
}
|
||||
|
||||
if windows.is_empty() {
|
||||
lines.push(" • No rate limit data available.".into());
|
||||
} else {
|
||||
let label_width = windows
|
||||
.iter()
|
||||
.map(|(label, _)| UnicodeWidthStr::width(*label))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
for (label, window) in windows {
|
||||
lines.push(build_status_limit_line(
|
||||
label,
|
||||
window.used_percent,
|
||||
label_width,
|
||||
));
|
||||
if let Some(resets_at) = window.resets_at.as_deref() {
|
||||
lines.push(build_status_reset_line(resets_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => lines.push(" • Send a message to load usage data.".into()),
|
||||
@@ -1651,6 +1715,10 @@ fn build_status_limit_line(label: &str, percent_used: f64, label_width: usize) -
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn build_status_reset_line(resets_at: &str) -> Line<'static> {
|
||||
vec![" ".into(), format!("Resets at: {resets_at}").dim()].into()
|
||||
}
|
||||
|
||||
fn render_status_limit_progress_bar(percent_used: f64) -> String {
|
||||
let ratio = (percent_used / 100.0).clamp(0.0, 1.0);
|
||||
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
|
||||
|
||||
Reference in New Issue
Block a user