Compare commits

...
5 Commits
Author SHA1 Message Date
ZyyandCodex c8fdc2b5cd chore(release): prepare v3.7.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:55:09 +08:00
2han9wen71an dfabf0f9dd fix(openai): recover quota after same-window usage drops (#95)
OpenAI 周窗口的 used_percent 可以在同一窗口内回落(滚动计量),而
reset_at 固定到窗口边界才变化。恢复规则要求「reset 前进 + 用量低于
10%」,两个条件在滑动周窗口上都无法成立:耗尽账号被钉在旧 reset
日期(最长一周)无法参与调度,尽管 /usage 已显式报告 allowed=true /
limit_reached=false 且 5h 窗口全新。

新增第二条解除路径:同一窗口(reset 未滚动)内连续两次观测都未触顶
即解除。候选记录持久化在 _quota_recovery,跨 worker 周期与重启累积;
连续两次的要求保持对「耗尽后立刻拉到的旧快照」竞态的防护(单次旧
快照撑不过两次观测)。原有「reset 前进 + 用量<10%」快路径不变。
2026-09-15 15:38:46 +08:00
ZyyandCodex 817b0f2d6d chore(release): commit version and notes together
Allow staged or unstaged release notes and commit their final contents with the version update before tagging. Continue rejecting unrelated tracked changes and require notes to remain uncommitted.

Update the release skill, skip duplicate push CI for release metadata alone, and check the publish script syntax in CI. Validated publication behavior in temporary Git repositories, Bash syntax, and Actionlint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:14:18 +08:00
ZyyandCodex a975868046 fix(release): publish version-matched deployment assets
Publish the configuration template and Compose file with release checksums, pin the packaged Compose image to the release version, and include the template in platform archives.

Download deployment assets from one release tag, link versioned deployment documentation, and translate the release installation notes into Chinese.

Validated Actionlint, stable and prerelease packaging for all platforms, SHA-256 checksums, and resolved Docker Compose image versions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:49 +08:00
ZyyandCodex cc2dfab19a fix(openai): preserve client location when unset
Accept omitted and null request locations without rewriting web search location or environment dates and timezones. Keep complete configured overrides validated and document the passthrough behavior.

Validated 626 OpenAI tests, 38 gateway tests including architecture checks, Rustfmt, and strict workspace Clippy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:36 +08:00
21 changed files with 348 additions and 147 deletions
+4 -3
View File
@@ -53,8 +53,9 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
@@ -63,7 +64,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
release/publish "$release_version"
```
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
## 恢复与验收
+3
View File
@@ -205,3 +205,6 @@ jobs:
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
.github/workflows/*.yml
- name: Check release script syntax
run: bash -n release/publish
+1
View File
@@ -6,6 +6,7 @@ on:
- main
paths-ignore:
- 'release/version.yaml'
- 'release/notes.md'
pull_request:
branches:
- main
+31 -14
View File
@@ -315,6 +315,7 @@ jobs:
shell: bash
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
run: |
set -euo pipefail
ruby <<'RUBY' > release-platforms.json
@@ -349,7 +350,18 @@ jobs:
shutil.rmtree(release_dir, ignore_errors=True)
release_dir.mkdir(parents=True)
archives = []
config_template = pathlib.Path("deploy/config.example.yaml")
shutil.copy2(config_template, release_dir / config_template.name)
compose = pathlib.Path("deploy/compose.yaml").read_text()
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
default_image = "${CPR_IMAGE:-" + image + ":latest}"
if compose.count(default_image) != 1:
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
compose = compose.replace(default_image, versioned_image)
(release_dir / "compose.yaml").write_text(compose)
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
for platform in platforms:
platform_name = f"{platform['os']}-{platform['arch']}"
@@ -367,6 +379,9 @@ jobs:
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
deploy_dir = package_root / "deploy"
deploy_dir.mkdir()
shutil.copy2(config_template, deploy_dir / config_template.name)
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
archive_path = release_dir / archive_name
@@ -375,12 +390,12 @@ jobs:
archive.add(package_root, arcname=APP_NAME)
else:
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
archives.append(archive_path)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
with checksum_path.open("w", encoding="utf-8") as file:
for archive in archives:
file.write(f"{sha256(archive)} {archive.name}\n")
for asset in assets:
file.write(f"{sha256(asset)} {asset.name}\n")
PY
- name: Generate release asset provenance
@@ -655,25 +670,27 @@ jobs:
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 Installation
## 📥 安装
**Docker:**
**Docker 部署:**
```bash
# GitHub Container Registry
# 从 GitHub 容器镜像仓库拉取
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
```
**Manual download:**
Download the appropriate archive for your platform from the assets below.
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
## 📚 Documentation
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
## 📚 文档
- [GitHub 仓库](https://github.com/${{ github.repository }})
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
run: |
python3 <<'PY'
import os
+9 -3
View File
@@ -21,7 +21,7 @@
## 快速开始
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
@@ -30,9 +30,12 @@
```bash
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
-o deploy/compose.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
-o deploy/config.example.yaml
install -d -m 0750 .runtime/postgres .runtime/redis
@@ -40,6 +43,9 @@ sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
分别生成数据库和 Redis 密码:
```bash
+9 -2
View File
@@ -311,7 +311,7 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
}
#[test]
fn config_loader_should_accept_default_and_custom_request_locations() {
fn config_loader_should_accept_empty_and_custom_request_locations() {
let original = valid_config();
let omitted = original
.lines()
@@ -319,7 +319,14 @@ fn config_loader_should_accept_default_and_custom_request_locations() {
.collect::<Vec<_>>()
.join("\n");
assert_ne!(original, omitted);
parse_config(&omitted).expect("location defaults when omitted");
parse_config(&omitted).expect("location passthrough when omitted");
let location_line = original
.lines()
.find(|line| line.trim_start().starts_with("location:"))
.expect("example location");
for empty in [" location:", " location: null", " location: ~"] {
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
}
let custom = original.replace(
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
+25 -20
View File
@@ -292,8 +292,9 @@ pub struct CodexWireProfileConfig {
pub terminal: String,
#[serde(default)]
pub residency: Option<CodexResidency>,
/// 为空时保留客户端的地区、环境日期和时区。
#[serde(default)]
pub location: CodexRequestLocation,
pub location: Option<CodexRequestLocation>,
pub verified_at: DateTime<Utc>,
}
@@ -309,7 +310,7 @@ impl Default for CodexWireProfileConfig {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: CodexRequestLocation::default(),
location: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
@@ -336,29 +337,33 @@ impl CodexWireProfileConfig {
("openai.wire_profile.os_version", self.os_version.as_str()),
("openai.wire_profile.arch", self.arch.as_str()),
("openai.wire_profile.terminal", self.terminal.as_str()),
(
"openai.wire_profile.location.region",
self.location.region.as_str(),
),
(
"openai.wire_profile.location.city",
self.location.city.as_str(),
),
] {
if value.trim().is_empty() {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if self.location.country.len() != 2
|| !self
.location
.country
.bytes()
.all(|byte| byte.is_ascii_uppercase())
{
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.location.country",
));
if let Some(location) = &self.location {
for (field, value) in [
(
"openai.wire_profile.location.region",
location.region.as_str(),
),
("openai.wire_profile.location.city", location.city.as_str()),
] {
if value.trim().is_empty() {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if location.country.len() != 2
|| !location
.country
.bytes()
.all(|byte| byte.is_ascii_uppercase())
{
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.location.country",
));
}
}
if semver::Version::parse(&self.codex_version).is_err() {
return Err(OpenAiConfigError::InvalidField(
@@ -18,6 +18,10 @@ pub(super) struct QuotaRecovery {
// 绑定本次耗尽事实,避免真实推理成功后再次耗尽时复用上次的恢复进度。
exhausted_at_micros: i64,
pending: BTreeMap<String, Option<DateTime<Utc>>>,
// 已连续观测到"未触顶"的待解除窗口;第二次确认后解除。旧文档没有该
// 字段时反序列化为空集,从当前耗尽事实重新积累证据。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
candidates: BTreeMap<String, ()>,
}
impl QuotaRecovery {
@@ -59,23 +63,44 @@ impl QuotaRecovery {
Self {
exhausted_at_micros: exhaustion_time(state),
pending,
candidates: BTreeMap::new(),
}
}
fn observe(&mut self, refreshed: &CodexAccountQuotaSnapshot) -> bool {
let had_baseline = !self.pending.is_empty();
let mut candidates = BTreeMap::new();
for window in refreshed.windows().iter().filter(|w| w.is_account_wide()) {
if let Some(previous_reset) = self.pending.get_mut(window.key()) {
if window_reset_recovered(window, *previous_reset) {
self.pending.remove(window.key());
} else if previous_reset.is_none() {
*previous_reset = window.reset_at();
let key = window.key();
if let Some(previous_reset) = self.pending.get(key).copied() {
if window_reset_recovered(window, previous_reset) {
self.pending.remove(key);
continue;
}
if previous_reset.is_none() {
// 首次观测到该窗口只锚定 reset 基线,不构成解除证据。
self.pending.insert(key.to_owned(), window.reset_at());
continue;
}
// 同一窗口内用量回落(reset 不滚动)时"reset 前进"永远不会
// 成立,滑动窗口的正常用量也长期高于低用量阈值;以上游连续
// 两次观测都报告未触顶为解除证据。单次观测无法排除耗尽后
// 立刻拉到的旧快照(reset 未滚动、用量尚未触顶)。
let rolled_forward = window
.reset_at()
.is_some_and(|current| Some(current) > previous_reset);
if !rolled_forward && !window.limit_reached() {
if self.candidates.contains_key(key) {
self.pending.remove(key);
} else {
candidates.insert(key.to_owned(), ());
}
}
} else if window.limit_reached() {
self.pending
.insert(window.key().to_owned(), window.reset_at());
self.pending.insert(key.to_owned(), window.reset_at());
}
}
self.candidates = candidates;
had_baseline && self.pending.is_empty()
}
}
@@ -85,15 +110,24 @@ fn exhaustion_time(state: QuotaState) -> i64 {
}
fn window_reset_recovered(window: &CodexQuotaWindow, previous: Option<DateTime<Utc>>) -> bool {
previous
// reset 必须前进:防止耗尽后立刻拉到的旧快照(reset 未滚动)误解锁。
if !previous
.zip(window.reset_at())
.is_some_and(|(previous, current)| current > previous)
&& window
.used_percent()
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
{
return false;
}
// reset 前进说明已进入新窗口,新窗口用量应接近零;高用量说明 reset 基线
// 不可信(例如滑动窗口),保持锁定,等待"同窗口用量回落"的连续观测解除。
window
.used_percent()
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
}
/// 统一手动刷新和 worker 的恢复规则;上游 allowed 不参与已耗尽窗口的解除判断。
/// 统一手动刷新和 worker 的恢复规则。已耗尽窗口的解除有两条证据路径:
/// reset 前进且用量低于低用量阈值(窗口已滚动),或同一窗口内连续两次观测
/// 都未触顶(滑动窗口的用量回落,reset 不滚动)。后者防止账号被钉在旧
/// reset 日期上;连续两次的要求排除耗尽后立刻拉到的旧快照。
pub(super) fn reconcile_refresh(
current: QuotaState,
refreshed: &mut CodexAccountQuotaSnapshot,
@@ -141,7 +141,7 @@ pub struct CodexProvider {
quota: Arc<CodexCredentialQuotaService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
location: CodexRequestLocation,
location: Option<CodexRequestLocation>,
responses_url: Url,
image_generations_url: Url,
image_edits_url: Url,
@@ -256,7 +256,8 @@ impl Provider for CodexProvider {
..Default::default()
};
};
let Ok(encoded) = encode_generate_request(request, "observability", &self.location) else {
let Ok(encoded) = encode_generate_request(request, "observability", self.location.as_ref())
else {
return ProviderRequestObservation::default();
};
let semantics = encoded.semantics();
@@ -358,7 +359,7 @@ impl Provider for CodexProvider {
let previous_session = decode_openai_session_state(generate);
let continuation_requested = generate.native_continuation_requested();
let mut upstream_request =
encode_generate_request(generate, upstream_model.as_str(), &self.location)
encode_generate_request(generate, upstream_model.as_str(), self.location.as_ref())
.map_err(map_request_error)?;
if let Some(conversation_id) = previous_session
.as_ref()
@@ -84,8 +84,8 @@ pub struct CodexWireProfile {
pub terminal: String,
/// 未配置时不发送 residency 头;不随制品版本更新而改变。
pub residency: Option<CodexResidency>,
/// 部署配置的固定地区,不随制品版本更新而改变。
pub location: CodexRequestLocation,
/// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。
pub location: Option<CodexRequestLocation>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。
pub verified_at: DateTime<Utc>,
}
@@ -97,7 +97,7 @@ pub enum CodexRequestEncodeError {
pub fn encode_generate_request(
request: &GenerateRequest,
upstream_model: &str,
location: &CodexRequestLocation,
location: Option<&CodexRequestLocation>,
) -> Result<CodexResponsesRequest, CodexRequestEncodeError> {
let payload = request.protocol_payload();
if payload.protocol() != "openai" {
@@ -116,13 +116,15 @@ pub fn encode_generate_request(
fn adapt_codex_responses_body(
body: &mut Map<String, Value>,
upstream_model: &str,
location: &CodexRequestLocation,
location: Option<&CodexRequestLocation>,
) {
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
body.remove(*field);
}
align_structured_location_fields(body, Utc::now(), location);
if let Some(location) = location {
align_structured_location_fields(body, Utc::now(), location);
}
}
fn align_structured_location_fields(
@@ -660,9 +660,8 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export
let Operation::Generate(request) = operation else {
panic!("connection test must be a generate operation");
};
let encoded =
provider_openai::encode_generate_request(&request, "gpt-5.4", &Default::default())
.expect("official OpenAI request");
let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4", None)
.expect("official OpenAI request");
assert_eq!(
encoded.body().get("model").and_then(Value::as_str),
Some("gpt-5.4")
+14 -16
View File
@@ -9,12 +9,9 @@ use provider_openai::transport::profile::CodexRequestLocation;
use serde_json::json;
#[test]
fn openai_location_defaults_should_match_the_existing_request_profile() {
fn openai_location_should_default_to_passthrough() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
assert_eq!(profile.location.country, "US");
assert_eq!(profile.location.region, "Ohio");
assert_eq!(profile.location.city, "Piketon");
assert_eq!(profile.location.timezone.name(), "America/New_York");
assert_eq!(profile.location, None);
}
#[test]
@@ -24,11 +21,14 @@ fn openai_config_should_preserve_a_custom_location_in_the_runtime_profile() {
}))
.expect("location configuration");
let mut config = valid_config();
config.wire_profile.location = location.clone();
config.wire_profile.location = Some(location.clone());
config
.resolve_and_validate(Path::new("/srv/gateway"))
.expect("valid location");
assert_eq!(config.wire_profile_state().snapshot().location, location);
assert_eq!(
config.wire_profile_state().snapshot().location,
Some(location)
);
}
#[test]
@@ -41,19 +41,17 @@ fn openai_config_should_reject_invalid_location_fields() {
("city", ""),
] {
let mut config = valid_config();
let location = config
.wire_profile
.location
.insert(CodexRequestLocation::default());
let (target, expected) = match field {
"country" => (
&mut config.wire_profile.location.country,
&mut location.country,
"openai.wire_profile.location.country",
),
"region" => (
&mut config.wire_profile.location.region,
"openai.wire_profile.location.region",
),
_ => (
&mut config.wire_profile.location.city,
"openai.wire_profile.location.city",
),
"region" => (&mut location.region, "openai.wire_profile.location.region"),
_ => (&mut location.city, "openai.wire_profile.location.city"),
};
*target = value.to_owned();
assert_eq!(
@@ -561,6 +561,102 @@ async fn an_unknown_slot_establishes_a_baseline_before_it_can_recover() {
}
}
#[tokio::test]
async fn weekly_usage_falling_back_within_the_same_window_recovers_after_two_observations() {
// 滑动周窗口的用量可以在同一窗口内回落:reset 不滚动、用量从 100% 降到
// 正常水平。此时"reset 前进 + 低用量"永远不会成立,解除依赖连续两次
// 观测都未触顶;单次观测无法排除耗尽后立刻拉到的旧快照,保持锁定。
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_weekly_fallout").await;
let account = store.account("acct_weekly_fallout").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
old["rate_limit"]["allowed"] = json!(false);
old["rate_limit"]["limit_reached"] = json!(true);
mount_usage(&server, old).await;
service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
// 5h 窗口滚动前进,周窗口 reset 不变但用量回落到正常水平:只标记候选。
let mut first_observation = usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET));
first_observation["rate_limit"]["allowed"] = json!(true);
mount_usage(&server, first_observation).await;
let first = service
.refresh_account(account.id())
.await
.expect("first fallout observation");
assert!(first.quota().is_exhausted());
// 第二次连续观测仍未触顶:解除周窗口。
let mut second_observation = usage((9, SHORT_RESET + 36_000), (18, WEEK_RESET));
second_observation["rate_limit"]["allowed"] = json!(true);
mount_usage(&server, second_observation).await;
let second = service
.refresh_account(account.id())
.await
.expect("second fallout observation");
assert_eq!(second.quota().access(), QuotaAccessState::Allowed);
}
#[tokio::test]
async fn a_single_same_window_fallout_observation_cannot_break_the_reset_baseline() {
// 连续性中断(观测到重新触顶)后必须重新积累两次未触顶证据。
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_weekly_fallout_gap").await;
let account = store.account("acct_weekly_fallout_gap").expect("account");
let server = MockServer::start().await;
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
old["rate_limit"]["allowed"] = json!(false);
mount_usage(&server, old).await;
service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
mount_usage(&server, usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("first candidate")
.quota()
.is_exhausted()
);
// 观测到重新触顶,候选证据清零。
mount_usage(&server, usage((0, SHORT_RESET + 36_000), (100, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("reached again")
.quota()
.is_exhausted()
);
mount_usage(&server, usage((0, SHORT_RESET + 54_000), (18, WEEK_RESET))).await;
assert!(
service
.refresh_account(account.id())
.await
.expect("candidate restarts")
.quota()
.is_exhausted()
);
mount_usage(&server, usage((0, SHORT_RESET + 72_000), (18, WEEK_RESET))).await;
assert_eq!(
service
.refresh_account(account.id())
.await
.expect("second consecutive observation")
.quota()
.access(),
QuotaAccessState::Allowed
);
}
#[tokio::test]
async fn monthly_and_arbitrary_periods_use_the_same_recovery_rule() {
for seconds in [18_000, 86_400, 604_800, 864_000, 2_592_000, 7_776_000] {
@@ -94,7 +94,7 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
let mut request = encode_generate_request(
&GenerateRequest::from_protocol_payload(payload),
"gpt-routed",
&Default::default(),
None,
)
.expect("opaque request headers");
request.use_websocket = use_websocket;
@@ -30,7 +30,8 @@ fn encoder_should_use_the_configured_location_for_tools_and_environment_dates()
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
let encoded = encode_generate_request(&request(body), "gpt-test", &location).expect("encode");
let encoded =
encode_generate_request(&request(body), "gpt-test", Some(&location)).expect("encode");
let after = Utc::now()
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
@@ -66,6 +67,31 @@ fn request(body: Map<String, Value>) -> GenerateRequest {
)
}
#[test]
fn encoder_should_preserve_location_fields_when_no_override_is_configured() {
let body = json!({
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context><current_date>2020-01-01</current_date><timezone>Asia/Shanghai</timezone></environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"],
"create_time": 1789293131.822
}
}],
"tools": [
{"type": "web_search", "user_location": {"type": "approximate", "country": "CN", "region": "Shanghai", "city": "Shanghai", "timezone": "Asia/Shanghai"}},
{"type": "web_search_preview", "user_location": null},
{"type": "web_search_preview_2025_03_11"},
{"type": "function", "name": "keep_timezone", "parameters": {}}
]
}).as_object().expect("request object").clone();
let encoded = encode_generate_request(&request(body.clone()), "gpt-test", None)
.expect("encode without location override");
assert_eq!(encoded.body().get("input"), body.get("input"));
assert_eq!(encoded.body().get("tools"), body.get("tools"));
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
}
#[test]
fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() {
let body = Map::from_iter([
@@ -87,8 +113,8 @@ fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_
]);
let request = request(body);
let encoded = encode_generate_request(&request, "gpt-routed", &Default::default())
.expect("encode wire payload");
let encoded =
encode_generate_request(&request, "gpt-routed", None).expect("encode wire payload");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-routed")));
assert!(encoded.body().get("stream").is_none());
@@ -121,8 +147,8 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity()
};
for input in ["private stable prompt", "different private prompt"] {
let encoded = encode_generate_request(&request(input), "gpt-routed", &Default::default())
.expect("encoded request");
let encoded =
encode_generate_request(&request(input), "gpt-routed", None).expect("encoded request");
assert!(encoded.local_conversation_id.is_none());
}
}
@@ -143,8 +169,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
),
]));
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
assert!(encoded.body().get("stream").is_none());
assert_eq!(encoded.body().get("store"), Some(&json!(false)));
@@ -164,8 +189,7 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() {
("temperature".to_owned(), json!(0.2)),
]));
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
Value::Object(encoded.body().clone()),
@@ -228,8 +252,12 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text()
.clone();
let before = Utc::now().with_timezone(&New_York);
let encoded =
encode_generate_request(&request(body), "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(
&request(body),
"gpt-test",
Some(&CodexRequestLocation::default()),
)
.expect("encode");
let after = Utc::now().with_timezone(&New_York);
let encoded = Value::Object(encoded.body().clone());
@@ -300,8 +328,7 @@ fn encoder_should_preserve_client_store_intent() {
("store".to_owned(), json!(true)),
]));
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.body().get("store"), Some(&json!(true)));
}
@@ -314,8 +341,7 @@ fn encoder_should_forward_the_client_prompt_cache_key() {
("prompt_cache_key".to_owned(), json!("cache-route")),
]));
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.body().get("prompt_cache_key"),
@@ -331,8 +357,7 @@ fn encoder_should_restore_conversation_fallback_after_body_and_context_values()
("prompt_cache_key".to_owned(), json!("cache-conversation")),
]));
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.client_conversation_id.as_deref(),
@@ -383,8 +408,7 @@ fn encoder_should_accept_metadata_session_and_thread_ids_but_ignore_legacy_conte
),
]));
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
(
@@ -430,8 +454,7 @@ fn encoder_should_extract_official_websocket_context_projection() {
),
]));
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("metadata-turn-state"));
assert_eq!(encoded.client_turn_id.as_deref(), Some("metadata-turn-id"));
@@ -472,8 +495,7 @@ fn header_context_should_win_over_body_topline_aliases() {
]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("header-turn-state"));
assert_eq!(
@@ -499,8 +521,7 @@ fn encoder_should_preserve_downstream_websocket_connection_identity_outside_wire
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.downstream_websocket_connection_id.as_deref(),
@@ -530,8 +551,7 @@ fn body_topline_alias_should_only_fill_an_absent_header_context() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("body-turn-state"));
assert_eq!(
@@ -556,8 +576,7 @@ fn encoder_should_project_explicit_websocket_transport_without_touching_body() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert!(encoded.use_websocket);
assert!(!encoded.force_http_sse);
@@ -580,8 +599,7 @@ fn encoder_should_project_explicit_http_transport_without_touching_body() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert!(!encoded.use_websocket);
assert!(encoded.force_http_sse);
@@ -607,8 +625,8 @@ fn encoder_should_preserve_opaque_provider_options_without_interpreting_them() {
.expect("OpenAI payload");
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test", &Default::default())
.expect("opaque options encode");
let encoded =
encode_generate_request(&request, "gpt-test", None).expect("opaque options encode");
assert_eq!(
encoded.body().get("provider_options"),
@@ -645,8 +663,7 @@ fn encoder_should_project_lite_and_memgen_options_to_transport_state_only() {
]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded =
encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.responses_lite.as_deref(), Some("true"));
assert_eq!(encoded.memgen_request.as_deref(), Some("true"));
@@ -670,7 +687,7 @@ fn observability_semantics_should_reuse_codex_turn_metadata() {
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let semantics = encode_generate_request(&request, "observability", &Default::default())
let semantics = encode_generate_request(&request, "observability", None)
.expect("observability request should encode")
.semantics();
@@ -689,9 +706,8 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() {
json!({"x-openai-subagent": "review"}),
),
]));
let metadata_encoded =
encode_generate_request(&metadata_request, "gpt-test", &Default::default())
.expect("encode metadata request");
let metadata_encoded = encode_generate_request(&metadata_request, "gpt-test", None)
.expect("encode metadata request");
assert_eq!(metadata_encoded.subagent_kind().as_deref(), Some("review"));
let turn_metadata_payload = ProtocolPayload::json_object(
@@ -707,9 +723,8 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() {
Value::String(r#"{"subagent_kind":"worker"}"#.to_owned()),
)]));
let turn_metadata_request = GenerateRequest::from_protocol_payload(turn_metadata_payload);
let turn_metadata_encoded =
encode_generate_request(&turn_metadata_request, "gpt-test", &Default::default())
.expect("encode turn metadata request");
let turn_metadata_encoded = encode_generate_request(&turn_metadata_request, "gpt-test", None)
.expect("encode turn metadata request");
assert_eq!(
turn_metadata_encoded.subagent_kind().as_deref(),
Some("worker")
@@ -742,7 +757,7 @@ fn observability_semantics_should_use_the_transparent_openai_payload() {
.expect("OpenAI payload");
let request = GenerateRequest::from_protocol_payload(payload);
let semantics = encode_generate_request(&request, "observability", &Default::default())
let semantics = encode_generate_request(&request, "observability", None)
.expect("transparent OpenAI request should encode")
.semantics();
+10 -2
View File
@@ -48,7 +48,9 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和
模板不重复列出这些默认项。运行后,Provider 检查官方版本并更新运行时请求画像,
不回写 `config.yaml`;检查失败时继续使用上一份有效画像。版本检查不等于重新核验 TLS。
`openai.wire_profile.location` 集中配置请求地区,省略时默认为 `US / Ohio / Piketon / America/New_York`。
`openai.wire_profile.location` 可选覆盖请求地区。省略、留空(`location:`)或设为 `null` 时,
透传客户端原有的 Web Search `user_location`、环境日期和时区;客户端未提供的字段也不会补写。
模板显式填写 `US / Ohio / Piketon / America/New_York`,需要透传时清空或删除该配置项。
自定义时完整填写 `country`(两位大写国家代码)、`region`、`city` 和 `timezone`(IANA 时区);修改后重启生效。
它统一 Responses 的 Web Search 地区与带环境标记的日期、时区,不修改普通聊天内容或 epoch 时间戳,
也不替代 `residency` 约束或随官方版本检查变化。
@@ -330,7 +332,13 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
> 以下命令只适用于同一大版本内的升级,不支持跨大版本在线升级。跨大版本请使用全新的
> `.runtime/` 数据目录重新部署,并重新导入或重新授权 Provider 账号与客户端 Key。
Docker 安装从安装目录拉取发布镜像并重建应用容器:
每个 Release 独立提供 `config.example.yaml`、默认镜像固定到该版本的 `compose.yaml` 和校验和;
各平台归档也包含 `deploy/config.example.yaml`。配置模板来自构建该版本的同一提交。
使用二进制归档手动部署时,将模板中的 `api.asset_directory` 改为 `../web/dist`,指向归档内的静态资源。
升级时先阅读目标版本说明,下载同一 Release 的部署附件,对比模板并合并必要配置,保留已有凭据
和 Compose 自定义项。不要用模板覆盖 `config.yaml`,也不要从 `main` 下载模板搭配旧镜像。
更新部署文件后,从安装目录拉取目标版本镜像并重建应用容器:
```bash
docker compose -f deploy/compose.yaml pull codex-proxy-rs
+2 -1
View File
@@ -74,7 +74,8 @@ openai:
terminal: 'unknown'
# 部署选择的 US residency;省略或 null 时使用官方无地域约束的默认分支。
residency: us
# 请求地区画像;省略 location 时使用以下默认值。时区同时用于环境日期,不改变 epoch 时间戳。
# 请求地区覆盖;省略或留空(location: / location: null)时透传客户端原值。
# 配置时完整填写四个字段;时区同时用于环境日期,不改变 epoch 时间戳。
location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }
verified_at: '2026-09-06T11:26:12.084+08:00'
+13 -10
View File
@@ -1,16 +1,19 @@
# v3.7.1
## 新增功能
- OpenAI 请求地区可通过 `openai.wire_profile.location` 统一配置国家、地区、城市和 IANA 时区,并同时用于 Web Search 地区与结构化环境日期。省略该配置时继续使用现有默认地区。
# v3.7.2
## 问题修复
- 修复 OpenAI WebSocket 上游在流中返回 `error` 事件时,SSE 客户端只能看到无原因断流的问题。网关现在将其转换为 `response.failed`,保留上游的错误类型、代码、消息和响应 ID,便于客户端识别并决定是否重试。
- 移除默认 Compose 部署中固定的 2 CPU 与 1 GiB 容器资源限制,允许服务在 CPU 少于 2 核的主机上启动;实际资源上限由部署环境决定。
- 修复 OpenAI 账号在同一额度窗口内用量回落后仍保持耗尽、无法参与调度的问题。连续两次额度观测确认相关窗口未触顶后可恢复,无需一直等待原重置日期;保留进入新窗口后的原有恢复路径。
- `openai.wire_profile.location` 支持留空、设为 `null` 或省略。未配置时透传客户端原有的 Web Search 地区、环境日期和时区,也不会补写客户端未提供的字段;完整填写时继续按配置覆盖。
## 发布与部署
- Release 附件新增同版本的 `config.example.yaml` 和 `compose.yaml`,并纳入 `checksums.txt` 校验。各平台归档同时包含 `deploy/config.example.yaml`。
- 发布附件中的 Compose 默认固定到该版本镜像,快速开始从同一 Release 下载部署文件,避免混用 `main` 分支配置与已发布镜像。
- 发布说明中的安装、下载与文档说明统一使用中文,部署文档链接固定到对应版本。
- 发版时将版本号与发布说明合并为一次提交,并在该提交上创建 tag。
## 升级说明
- 从 v3.7.0 升级无需数据库迁移,已有配置可以继续使用,`openai.wire_profile.location` 为可选项。
- 使用新版 `deploy/config.example.yaml` 中的 `openai.wire_profile.location` 时,需要同步升级到 v3.7.1 镜像;v3.7.0 镜像会因严格配置校验拒绝该字段。
- 需要解除默认 CPU 和内存限制的部署应同步更新 `deploy/compose.yaml`。自行设置的宿主机或容器资源限制仍然生效。
- 从 v3.7.1 升级无需新增数据库迁移,已有完整的 `location` 配置继续生效。
- 省略 `location` 时,行为由默认覆盖为 `US / Ohio / Piketon / America/New_York` 改为透传。需要保留原覆盖行为的部署应显式填写这四个字段;使用 `location: null` 需要升级到 v3.7.2。
- 升级前对比本版本配置模板并合并必要配置,保留已有凭据和 Compose 自定义项,不要用模板覆盖 `config.yaml`。使用二进制归档手动部署时,将 `api.asset_directory` 设为 `../web/dist`。
+12 -8
View File
@@ -16,8 +16,9 @@ Usage:
Example:
release/publish 0.1.4
Before publishing, replace release/notes.md with the new version's notes and
commit it. Its first line must be "# v<version>".
Before publishing, replace release/notes.md with the new version's notes,
starting with "# v<version>". Leave the notes uncommitted; this command commits
them together with release/version.yaml. Commit other tracked changes first.
USAGE
}
@@ -94,11 +95,11 @@ remote_url="$(git remote get-url "$remote")" || die "failed to resolve remote UR
repository="$(gh repo view "$remote_url" --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null)" ||
die "failed to resolve GitHub repository for $remote_url"
if ! git diff --quiet --ignore-submodules --; then
die "tracked files have unstaged changes; commit or stash them first"
if ! git diff --quiet --ignore-submodules -- . ":(exclude)$NOTES_FILE"; then
die "tracked files other than $NOTES_FILE have unstaged changes; commit or stash them first"
fi
if ! git diff --cached --quiet --ignore-submodules --; then
die "index has staged changes; commit or unstage them first"
if ! git diff --cached --quiet --ignore-submodules -- . ":(exclude)$NOTES_FILE"; then
die "index has staged changes outside $NOTES_FILE; commit or unstage them first"
fi
current_version="$(read_version_key version)"
@@ -108,6 +109,9 @@ current_version="${current_version#v}"
tag="${TAG_PREFIX}${version}"
git ls-files --error-unmatch "$NOTES_FILE" >/dev/null 2>&1 || die "$NOTES_FILE is not tracked"
if git diff HEAD --quiet -- "$NOTES_FILE"; then
die "$NOTES_FILE must have uncommitted changes so notes and version are committed together"
fi
ruby - "$NOTES_FILE" "$tag" <<'RUBY'
path, tag = ARGV
lines = File.readlines(path)
@@ -141,9 +145,9 @@ fi
update_version_file "$version"
git add "$VERSION_FILE"
git add -- "$VERSION_FILE" "$NOTES_FILE"
git commit \
-m "chore: bump version to $version" \
-m "chore(release): prepare $tag" \
-m "$SIGNATURE"
git tag -a "$tag" -m "$tag"
+1 -1
View File
@@ -1 +1 @@
version: "3.7.1"
version: "3.7.2"