Compare commits

...
12 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
ZyyandCodex 040a1e82a6 chore: bump version to 3.7.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:29 +08:00
ZyyandCodex 4f39bfc5a8 docs(release): prepare v3.7.1 notes
Document configurable request location, WebSocket SSE error delivery, relaxed Compose resource limits, and the required image/config version alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:20 +08:00
ZyyandCodex 3e3616863d fix(deploy): remove fixed container resource limits
Allow the default Compose deployment to run on hosts with fewer than two CPUs and leave CPU and memory policy to the deployment environment.

Validated the rendered Docker Compose configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:01:21 +08:00
1bbae67040 fix(gateway-api): translate upstream WebSocket error events into SSE response.failed (#90)
* fix(gateway-api): translate upstream WebSocket error events into SSE response.failed

WS 上游协议用 `error` 事件报告流中途失败(如 OpenAI 的 server_is_overloaded),
SSE 协议的对应形态是 `response.failed`。SSE 交付边界此前把 `error` 事件按
未知事件原样透传,而 codex 等 SSE 客户端会静默忽略未知事件类型:客户端只看到
无原因的流 EOF("stream closed before response.completed"),丢失了上游的
完整错误负载(message/code/type),也无法据此分类重试。

现在 SSE 编码器在交付边界把 `error` wire 事件翻译成 `response.failed`:
错误负载(type/code/message/param)原样保留,response id 沿用已交付的
response.created。WS 客户端交付不变(WS 协议原生支持 error 帧)。
原有的会话级失败兜底(finish_with_gateway_error 合成 response.failed)不变。

* fix(gateway-api): preserve upstream response failures

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex fc07ac0803 fix(ci): scope rustls security waiver
Temporarily waive RUSTSEC-2026-0285 until 2026-09-21 UTC to preserve the pinned Codex TLS fingerprint. Keep all other audit failures blocking.

Validated cargo audit and waiver expiry behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex c7823a7821 feat(openai): make request location configurable
Group country, region, city, and timezone under openai.wire_profile.location while retaining the existing defaults. Share the configured location across web search and environment context, preserve epoch timestamps and release updates, and add validation, regression tests, and deployment documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex 61d1bcb405 refactor(api): standardize request parameters and trim unused options
Use data for POST bodies and params: data for GET queries. Remove request options that have no callers while retaining cancellation, timeout, and silent polling support. Update affected backup and client-download call sites and document the convention.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:25 +08:00
50 changed files with 1016 additions and 221 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
+10 -1
View File
@@ -40,7 +40,16 @@ jobs:
- name: Run cargo audit
working-directory: backend
run: cargo audit
shell: bash
run: |
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
audit_advisory_args=()
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
fi
cargo audit "${audit_advisory_args[@]}"
frontend-security:
runs-on: ubuntu-latest
+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
+1
View File
@@ -708,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
"serde",
]
[[package]]
+33
View File
@@ -310,6 +310,39 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
}
#[test]
fn config_loader_should_accept_empty_and_custom_request_locations() {
let original = valid_config();
let omitted = original
.lines()
.filter(|line| !line.trim_start().starts_with("location:"))
.collect::<Vec<_>>()
.join("\n");
assert_ne!(original, 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' }",
);
assert_ne!(original, custom);
parse_config(&custom).expect("custom location from YAML");
}
#[test]
fn config_loader_should_reject_invalid_request_location_timezones() {
let original = valid_config();
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
assert_ne!(original, invalid);
assert_rejected(invalid);
}
fn assert_rejected(config: String) {
assert!(parse_config(&config).is_err());
}
@@ -2,7 +2,9 @@
use bytes::Bytes;
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
use gateway_protocol::openai::sse::{
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
};
use serde_json::Value;
use super::error::ResponseEncodeError;
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
response_snapshot: Option<Value>,
wire_terminal: Option<Value>,
wire_failure: bool,
}
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
pub const fn new() -> Self {
Self {
response_id: None,
response_snapshot: None,
wire_terminal: None,
wire_failure: false,
}
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
"response.failed",
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
return vec![raw_sse_frame.clone()];
}
@@ -110,6 +130,16 @@ impl OpenAiResponsesEncoder {
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
) && let Some(response) = wire
.data()
.get("response")
.filter(|value| value.is_object())
{
self.response_snapshot = Some(response.clone());
}
if matches!(
effective_type,
Some("response.completed" | "response.incomplete")
@@ -34,6 +34,7 @@ use gateway_core::event::{
use gateway_core::operation::{Operation, OperationKind};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
);
}
#[tokio::test]
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
),
(
json!({
"type": "response.failed",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -2,7 +2,7 @@
use std::fmt;
use serde_json::json;
use serde_json::{Value, json};
use thiserror::Error;
/// 单条 SSE 事件。
@@ -277,12 +277,7 @@ pub fn response_failed_sse_data_with_id(
"code": code,
"message": message,
});
let response_id = response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
);
let response_id = response_id_or_generated(response_id);
json!({
"type": "response.failed",
"response": {
@@ -294,6 +289,50 @@ pub fn response_failed_sse_data_with_id(
})
}
/// 将携带嵌套错误的上游事件投影为 OpenAI Responses `response.failed` 数据。
///
/// 该投影供客户端 SSE 兼容转换使用。原始错误对象和事件级元数据保持不透明;
/// 已有 response 快照只更新失败终态字段。
pub fn response_failed_sse_data_from_error_event(
response: Option<&Value>,
response_id: Option<&str>,
error_event: &Value,
) -> Option<Value> {
let mut event = error_event.as_object()?.clone();
let error = event.remove("error").filter(Value::is_object)?;
let mut response = response
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let has_response_id = response
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if !has_response_id {
response.insert(
"id".to_owned(),
Value::String(response_id_or_generated(response_id)),
);
}
response.insert("status".to_owned(), Value::String("failed".to_owned()));
response.insert("error".to_owned(), error);
event.insert(
"type".to_owned(),
Value::String("response.failed".to_owned()),
);
event.insert("response".to_owned(), Value::Object(response));
Some(Value::Object(event))
}
fn response_id_or_generated(response_id: Option<&str>) -> String {
response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
)
}
/// 返回下一个完整 SSE 帧结束位置(含分隔符)。
pub fn sse_frame_end(bytes: &[u8]) -> Option<usize> {
sse_frame_separator_bytes(bytes).map(|(position, separator_len)| position + separator_len)
@@ -1,8 +1,10 @@
use gateway_protocol::openai::sse::{
DONE_SSE_FRAME, MAX_SSE_EVENT_BUFFER_BYTES, SseError, SseEvent, SseEventDecoder,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events, response_failed_sse_event,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events,
response_failed_sse_data_from_error_event, response_failed_sse_event,
response_failed_sse_event_with_id, sse_frame_end, sse_frame_is_done,
};
use serde_json::json;
fn sse_body_has_done(body: &str) -> bool {
body.trim_end_matches(['\r', '\n'])
@@ -386,6 +388,57 @@ fn response_failed_event_should_preserve_existing_response_id() {
assert_eq!(data["response"]["id"], "resp_existing");
}
#[test]
fn response_failed_projection_should_preserve_response_error_and_event_metadata() {
let response = json!({
"id": "resp_existing",
"model": "gpt-test",
"status": "in_progress",
"future_response_field": {"keep": true}
});
let error_event = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected = response_failed_sse_data_from_error_event(
Some(&response),
Some("ignored_fallback"),
&error_event,
)
.expect("wrapped error event should project");
assert_eq!(
projected,
json!({
"type": "response.failed",
"response": {
"id": "resp_existing",
"model": "gpt-test",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
})
);
}
#[test]
fn done_frame_detection_should_require_exact_single_data_field() {
assert_eq!(
+1 -1
View File
@@ -13,7 +13,7 @@ async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
chrono-tz = "0.10"
chrono-tz = { version = "0.10", features = ["serde"] }
cookie = "0.18.1"
futures = "0.3.32"
flate2 = "1.1.9"
+31 -1
View File
@@ -8,7 +8,9 @@ use serde::Deserialize;
use url::{Host, Url};
use crate::credential::CodexQuotaRefreshPolicy;
use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState};
use crate::transport::profile::{
CodexRequestLocation, CodexResidency, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
use crate::transport::websocket::CodexWebSocketPoolConfig;
use crate::{
@@ -290,6 +292,9 @@ pub struct CodexWireProfileConfig {
pub terminal: String,
#[serde(default)]
pub residency: Option<CodexResidency>,
/// 为空时保留客户端的地区、环境日期和时区。
#[serde(default)]
pub location: Option<CodexRequestLocation>,
pub verified_at: DateTime<Utc>,
}
@@ -305,6 +310,7 @@ impl Default for CodexWireProfileConfig {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
@@ -336,6 +342,29 @@ impl CodexWireProfileConfig {
return Err(OpenAiConfigError::InvalidField(field));
}
}
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(
"openai.wire_profile.codex_version",
@@ -367,6 +396,7 @@ impl From<CodexWireProfileConfig> for CodexWireProfile {
arch: value.arch,
terminal: value.terminal,
residency: value.residency,
location: value.location,
verified_at: value.verified_at,
}
}
@@ -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,
@@ -72,7 +72,7 @@ use crate::transport::diagnostics::{
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
};
use crate::transport::profile::{
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexRequestLocation, CodexWireProfileState,
};
use crate::transport::protocol::responses::{
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
@@ -141,6 +141,7 @@ pub struct CodexProvider {
quota: Arc<CodexCredentialQuotaService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
location: Option<CodexRequestLocation>,
responses_url: Url,
image_generations_url: Url,
image_edits_url: Url,
@@ -173,6 +174,7 @@ impl CodexProvider {
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let search_url = Url::parse(&endpoint_url(&base_url, CODEX_ALPHA_SEARCH_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let location = profile.snapshot().location;
let client =
CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool);
Ok(Self {
@@ -181,6 +183,7 @@ impl CodexProvider {
quota,
account_feedback,
client,
location,
responses_url,
image_generations_url,
image_edits_url,
@@ -253,7 +256,8 @@ impl Provider for CodexProvider {
..Default::default()
};
};
let Ok(encoded) = encode_generate_request(request, "observability") else {
let Ok(encoded) = encode_generate_request(request, "observability", self.location.as_ref())
else {
return ProviderRequestObservation::default();
};
let semantics = encoded.semantics();
@@ -354,8 +358,9 @@ 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())
.map_err(map_request_error)?;
let mut upstream_request =
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()
.and_then(|state| state.conversation_id.as_ref())
@@ -39,6 +39,27 @@ pub enum CodexResidency {
Us,
}
/// 环境上下文与 Web Search 共用的请求地区画像。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CodexRequestLocation {
pub country: String,
pub region: String,
pub city: String,
pub timezone: chrono_tz::Tz,
}
impl Default for CodexRequestLocation {
fn default() -> Self {
Self {
country: "US".to_owned(),
region: "Ohio".to_owned(),
city: "Piketon".to_owned(),
timezone: chrono_tz::America::New_York,
}
}
}
/// Codex Desktop 上游请求身份。
///
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
@@ -63,6 +84,8 @@ pub struct CodexWireProfile {
pub terminal: String,
/// 未配置时不发送 residency 头;不随制品版本更新而改变。
pub residency: Option<CodexResidency>,
/// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。
pub location: Option<CodexRequestLocation>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。
pub verified_at: DateTime<Utc>,
}
@@ -4,7 +4,6 @@ use std::io;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::{DateTime, Utc};
use chrono_tz::America::New_York;
use gateway_core::operation::GenerateRequest;
use gateway_protocol::openai::{
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header,
@@ -12,9 +11,10 @@ use gateway_protocol::openai::{
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use roxmltree::Document;
use serde::Serialize as _;
use serde_json::{Map, Value};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use crate::transport::profile::CodexRequestLocation;
use crate::transport::protocol::responses::{
CodexResponsesRequest, X_CODEX_TURN_STATE_CLIENT_METADATA_KEY,
};
@@ -24,11 +24,6 @@ const TURN_ID_CLIENT_METADATA_KEY: &str = "turn_id";
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
const ENVIRONMENT_CONTEXT_CONTENT_KIND: &str = "environments.environment_context";
// 请求中的地区画像以 PORTS 所在的 Piketon 为基准;epoch 时间戳仍保持绝对时间原值。
const TARGET_COUNTRY: &str = "US";
const TARGET_REGION: &str = "Ohio";
const TARGET_CITY: &str = "Piketon";
const TARGET_TIMEZONE: &str = "America/New_York";
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
@@ -102,13 +97,14 @@ pub enum CodexRequestEncodeError {
pub fn encode_generate_request(
request: &GenerateRequest,
upstream_model: &str,
location: Option<&CodexRequestLocation>,
) -> Result<CodexResponsesRequest, CodexRequestEncodeError> {
let payload = request.protocol_payload();
if payload.protocol() != "openai" {
return Err(CodexRequestEncodeError::InvalidProtocolPayload);
}
let mut body = payload.body().clone();
adapt_codex_responses_body(&mut body, upstream_model);
adapt_codex_responses_body(&mut body, upstream_model, location);
let mut encoded = CodexResponsesRequest::from_body(body);
encoded.explicit_prompt_cache_key = encoded.prompt_cache_key().is_some();
@@ -117,29 +113,43 @@ pub fn encode_generate_request(
Ok(encoded)
}
fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &str) {
fn adapt_codex_responses_body(
body: &mut Map<String, Value>,
upstream_model: &str,
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());
if let Some(location) = location {
align_structured_location_fields(body, Utc::now(), location);
}
}
fn align_structured_location_fields(body: &mut Map<String, Value>, now: DateTime<Utc>) {
let current_date = now.with_timezone(&New_York).format("%Y-%m-%d").to_string();
fn align_structured_location_fields(
body: &mut Map<String, Value>,
now: DateTime<Utc>,
location: &CodexRequestLocation,
) {
// 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值。
let current_date = now
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
for item in input {
align_environment_context(item, &current_date);
align_environment_context(item, &current_date, location.timezone.name());
}
}
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
for tool in tools {
align_web_search_location(tool);
align_web_search_location(tool, location);
}
}
}
fn align_environment_context(item: &mut Value, current_date: &str) {
fn align_environment_context(item: &mut Value, current_date: &str, timezone: &str) {
let Some(item) = item.as_object_mut() else {
return;
};
@@ -178,13 +188,13 @@ fn align_environment_context(item: &mut Value, current_date: &str) {
let Some(Value::String(text)) = part.get_mut("text") else {
continue;
};
if let Some(aligned) = aligned_environment_context(text, current_date) {
if let Some(aligned) = aligned_environment_context(text, current_date, timezone) {
*text = aligned;
}
}
}
fn aligned_environment_context(text: &str, current_date: &str) -> Option<String> {
fn aligned_environment_context(text: &str, current_date: &str, timezone: &str) -> Option<String> {
let trimmed = text.trim();
if !trimmed.starts_with("<environment_context>") || !trimmed.ends_with("</environment_context>")
{
@@ -201,7 +211,7 @@ fn aligned_environment_context(text: &str, current_date: &str) -> Option<String>
.filter_map(|node| {
let replacement = match node.tag_name().name() {
"current_date" => format!("<current_date>{current_date}</current_date>"),
"timezone" => format!("<timezone>{TARGET_TIMEZONE}</timezone>"),
"timezone" => format!("<timezone>{timezone}</timezone>"),
_ => return None,
};
Some((node.range(), replacement))
@@ -218,7 +228,7 @@ fn aligned_environment_context(text: &str, current_date: &str) -> Option<String>
Some(aligned)
}
fn align_web_search_location(tool: &mut Value) {
fn align_web_search_location(tool: &mut Value, location: &CodexRequestLocation) {
let Some(tool) = tool.as_object_mut() else {
return;
};
@@ -230,19 +240,13 @@ fn align_web_search_location(tool: &mut Value) {
}
tool.insert(
"user_location".to_owned(),
Value::Object(Map::from_iter([
("type".to_owned(), Value::String("approximate".to_owned())),
(
"country".to_owned(),
Value::String(TARGET_COUNTRY.to_owned()),
),
("region".to_owned(), Value::String(TARGET_REGION.to_owned())),
("city".to_owned(), Value::String(TARGET_CITY.to_owned())),
(
"timezone".to_owned(),
Value::String(TARGET_TIMEZONE.to_owned()),
),
])),
json!({
"type": "approximate",
"country": location.country,
"region": location.region,
"city": location.city,
"timezone": location.timezone.name(),
}),
);
}
@@ -660,7 +660,7 @@ 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")
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),
@@ -1347,6 +1347,7 @@ fn valid_config() -> TestOpenAiConfig {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
@@ -3,7 +3,74 @@ use std::path::Path;
use chrono::{TimeZone as _, Utc};
use provider_openai::config::{
CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig,
OpenAiConfigError,
};
use provider_openai::transport::profile::CodexRequestLocation;
use serde_json::json;
#[test]
fn openai_location_should_default_to_passthrough() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
assert_eq!(profile.location, None);
}
#[test]
fn openai_config_should_preserve_a_custom_location_in_the_runtime_profile() {
let location: CodexRequestLocation = serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
.expect("location configuration");
let mut config = valid_config();
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,
Some(location)
);
}
#[test]
fn openai_config_should_reject_invalid_location_fields() {
for (field, value) in [
("country", "USA"),
("country", "us"),
("country", ""),
("region", " "),
("city", ""),
] {
let mut config = valid_config();
let location = config
.wire_profile
.location
.insert(CodexRequestLocation::default());
let (target, expected) = match field {
"country" => (
&mut location.country,
"openai.wire_profile.location.country",
),
"region" => (&mut location.region, "openai.wire_profile.location.region"),
_ => (&mut location.city, "openai.wire_profile.location.city"),
};
*target = value.to_owned();
assert_eq!(
config.resolve_and_validate(Path::new("/srv/gateway")),
Err(OpenAiConfigError::InvalidField(expected))
);
}
}
#[test]
fn location_config_should_reject_unknown_timezones_and_incomplete_groups() {
for value in [
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "Not/A_Timezone" }),
json!({ "timezone": "Pacific/Auckland" }),
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "America/New_York", "extra": true }),
] {
assert!(serde_json::from_value::<CodexRequestLocation>(value).is_err());
}
}
#[test]
fn openai_config_builds_the_audited_wire_profile() {
@@ -154,6 +221,7 @@ fn valid_config() -> OpenAiConfig {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
.single()
@@ -65,6 +65,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "catalog-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -161,6 +161,7 @@ fn selector_with_runtime(
arch: "x86_64".to_owned(),
terminal: "selector-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: chrono::Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
@@ -22,6 +22,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "profile-statistics-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
.single()
@@ -38,6 +38,7 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "quota-contract".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -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] {
@@ -138,6 +138,9 @@ fn wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "provider-contract".to_owned(),
residency: None,
location: serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
})).expect("configured request location"),
verified_at: Utc::now(),
})
}
@@ -467,6 +470,7 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_websocket_turn_state",
"acct_ws_quota_a",
"acct_ws_quota_b",
"acct_ws_midstream_overload",
]
.into_iter()
.map(|id| {
@@ -664,6 +668,47 @@ async fn capture_scoped_http_request(
requests.pop().expect("single scoped request")
}
#[tokio::test]
async fn provider_should_send_the_configured_location_to_the_upstream() {
let body = json!({
"model": "gpt-5.4",
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context><timezone>UTC</timezone></environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"],
"create_time": 1789293131.822
}
}],
"tools": [{"type": "web_search"}]
}).as_object().expect("request object").clone();
let captured = capture_scoped_http_request(
"req_configured_location",
"acct_provider_contract",
"acct_provider_contract",
body,
Map::new(),
)
.await;
let body = captured_request_body(&captured);
assert_eq!(
body.pointer("/tools/0/user_location"),
Some(&json!({
"type": "approximate", "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
);
assert_eq!(
body.pointer("/input/0/content/0/text"),
Some(&json!(
"<environment_context><timezone>Pacific/Auckland</timezone></environment_context>"
))
);
assert_eq!(
body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
Some(&json!(1789293131.822))
);
}
async fn capture_turn_state_request(
request_id: &str,
previous_turn_id: Option<&str>,
@@ -2329,6 +2374,114 @@ async fn abrupt_websocket_disconnect_preserves_diagnosis_and_ambiguous_send_stat
);
}
#[tokio::test]
async fn websocket_midstream_error_frame_surfaces_upstream_message_after_delivery() {
const ACCOUNT_ID: &str = "acct_ws_midstream_overload";
const RESPONSE_ID: &str = "resp_midstream_overload";
const OVERLOAD_MESSAGE: &str = "Our servers are currently overloaded. Please try again later.";
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, ACCOUNT_ID).await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept WebSocket");
let mut websocket = accept_codex_test_websocket(stream).await;
let _request = websocket
.next()
.await
.expect("WebSocket request")
.expect("valid WebSocket request");
// 真实生产观察:OpenAI 在流已开始后发送带原话的 `error` 帧再断连。
websocket
.send(Message::Text(
json!({
"type": "response.created",
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
})
.to_string()
.into(),
))
.await
.expect("send response.created");
websocket
.send(Message::Text(
json!({
"type": "response.in_progress",
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
})
.to_string()
.into(),
))
.await
.expect("send response.in_progress");
tokio::time::sleep(Duration::from_millis(1_600)).await;
websocket
.send(Message::Text(
json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": OVERLOAD_MESSAGE,
"param": null
},
"sequence_number": 2
})
.to_string()
.into(),
))
.await
.expect("send error frame");
// 不发送任何终止事件,直接断开,模拟上游发完错误帧后的真实行为。
});
let provider = provider_with_base_url(&store, base_url);
let mut stream = provider
.execute(
planned_request("openai", generate_operation()),
context("req_ws_midstream_overload", CancellationToken::new()),
)
.await
.expect("prepare midstream-failure stream");
let mut failure = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("midstream error frame must surface a typed failure"),
}
};
server.abort();
assert_eq!(
failure.kind(),
ProviderErrorKind::UpstreamCapacityUnavailable
);
assert_eq!(failure.send_state(), UpstreamSendState::Sent);
// 交给 Core 的失败必须保留上游原话:客户端只能靠它知道失败原因。
let visible = failure
.client_visible_upstream_error()
.expect("overload frame must carry a client-visible upstream error");
assert_eq!(visible.code(), Some("server_is_overloaded"));
assert_eq!(visible.message(), OVERLOAD_MESSAGE);
// 原始错误帧必须随失败一起交给交付边界(SSE 侧据此翻译成 response.failed)。
let atomic = failure.take_atomic_client_events();
let error_frame = atomic
.iter()
.find_map(|event| {
let wire = event.wire_event()?;
(wire.event_type() == Some("error")).then(|| wire.data().clone())
})
.expect("atomic batch must carry the upstream error frame");
assert_eq!(
error_frame
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str),
Some(OVERLOAD_MESSAGE)
);
}
#[tokio::test]
async fn websocket_idle_timeout_diagnosis_survives_ambiguous_send_wrapping() {
const ACCOUNT_ID: &str = "acct_abrupt_disconnect";
@@ -419,6 +419,7 @@ fn profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -94,6 +94,7 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
let mut request = encode_generate_request(
&GenerateRequest::from_protocol_payload(payload),
"gpt-routed",
None,
)
.expect("opaque request headers");
request.use_websocket = use_websocket;
@@ -131,6 +131,7 @@ fn test_wire_profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "transport-test".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc::now(),
})
}
@@ -141,6 +141,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
@@ -207,7 +208,11 @@ fn desktop_appcast_should_reject_invalid_version_and_build() {
#[tokio::test]
async fn bundled_release_update_should_change_core_and_desktop_identity_atomically() {
let original = wire_profile();
let mut original = wire_profile();
original.location = serde_json::from_value(serde_json::json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
.expect("configured location");
let original_user_agent = original.user_agent();
let state = CodexWireProfileState::new(original.clone());
let transport = Arc::new(ReleaseTransport::new(
@@ -228,6 +233,7 @@ async fn bundled_release_update_should_change_core_and_desktop_identity_atomical
assert_eq!(updated.os_version, original.os_version);
assert_eq!(updated.arch, original.arch);
assert_eq!(updated.terminal, original.terminal);
assert_eq!(updated.location, original.location);
assert!(updated.verified_at > original.verified_at);
assert_ne!(updated.user_agent(), original_user_agent);
assert_eq!(
@@ -344,6 +350,7 @@ fn wire_profile() -> CodexWireProfile {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
@@ -4,6 +4,62 @@ use gateway_core::operation::{GenerateRequest, ProtocolPayload};
use serde_json::{Map, Value, json};
use provider_openai::encode_generate_request;
use provider_openai::transport::profile::CodexRequestLocation;
#[test]
fn encoder_should_use_the_configured_location_for_tools_and_environment_dates() {
let location: CodexRequestLocation = serde_json::from_value(json!({
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
.expect("configured location");
let body = json!({
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context><current_date>2020-01-01</current_date><timezone>UTC</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": "US"}},
{"type": "web_search_preview"}
]
}).as_object().expect("request object").clone();
let before = Utc::now()
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
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")
.to_string();
let encoded = Value::Object(encoded.body().clone());
let environment = encoded
.pointer("/input/0/content/0/text")
.and_then(Value::as_str)
.expect("environment");
assert!(environment.contains("<timezone>Pacific/Auckland</timezone>"));
assert!(
[before, after]
.iter()
.any(|date| environment.contains(&format!("<current_date>{date}</current_date>")))
);
for index in 0..2 {
assert_eq!(
encoded.pointer(&format!("/tools/{index}/user_location")),
Some(&json!({
"type": "approximate", "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
}))
);
}
assert_eq!(
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
Some(&json!(1789293131.822))
);
}
fn request(body: Map<String, Value>) -> GenerateRequest {
GenerateRequest::from_protocol_payload(
@@ -11,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([
@@ -32,7 +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").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());
@@ -66,7 +148,7 @@ 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").expect("encoded request");
encode_generate_request(&request(input), "gpt-routed", None).expect("encoded request");
assert!(encoded.local_conversation_id.is_none());
}
}
@@ -87,7 +169,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
),
]));
let encoded = encode_generate_request(&request, "gpt-test").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)));
@@ -107,7 +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").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
Value::Object(encoded.body().clone()),
@@ -170,7 +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").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());
@@ -241,7 +328,7 @@ fn encoder_should_preserve_client_store_intent() {
("store".to_owned(), json!(true)),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(encoded.body().get("store"), Some(&json!(true)));
}
@@ -254,7 +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").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.body().get("prompt_cache_key"),
@@ -270,7 +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").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.client_conversation_id.as_deref(),
@@ -321,7 +408,7 @@ fn encoder_should_accept_metadata_session_and_thread_ids_but_ignore_legacy_conte
),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
(
@@ -367,7 +454,7 @@ fn encoder_should_extract_official_websocket_context_projection() {
),
]));
let encoded = encode_generate_request(&request, "gpt-test").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"));
@@ -408,7 +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").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!(
@@ -434,7 +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").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert_eq!(
encoded.downstream_websocket_connection_id.as_deref(),
@@ -464,7 +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").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!(
@@ -489,7 +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").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert!(encoded.use_websocket);
assert!(!encoded.force_http_sse);
@@ -512,7 +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").expect("encode");
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
assert!(!encoded.use_websocket);
assert!(encoded.force_http_sse);
@@ -538,7 +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").expect("opaque options encode");
let encoded =
encode_generate_request(&request, "gpt-test", None).expect("opaque options encode");
assert_eq!(
encoded.body().get("provider_options"),
@@ -575,7 +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").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"));
@@ -599,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")
let semantics = encode_generate_request(&request, "observability", None)
.expect("observability request should encode")
.semantics();
@@ -618,8 +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").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(
@@ -635,7 +723,7 @@ 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")
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(),
@@ -669,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")
let semantics = encode_generate_request(&request, "observability", None)
.expect("transparent OpenAI request should encode")
.semantics();
@@ -27,6 +27,7 @@ fn client(base_url: &str) -> CodexBackendClient {
arch: "arm64".to_owned(),
terminal: "xterm-256color".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 20, 0, 0, 0)
.single()
@@ -705,6 +705,7 @@ fn profile() -> CodexWireProfileState {
arch: "x86_64".to_owned(),
terminal: "xterm".to_owned(),
residency: None,
location: Default::default(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
+14 -1
View File
@@ -48,6 +48,13 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和
模板不重复列出这些默认项。运行后,Provider 检查官方版本并更新运行时请求画像,
不回写 `config.yaml`;检查失败时继续使用上一份有效画像。版本检查不等于重新核验 TLS。
`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` 约束或随官方版本检查变化。
## 启动
```bash
@@ -325,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
View File
@@ -86,8 +86,6 @@ services:
redis:
condition: service_healthy
pids_limit: 256
cpus: '2.0'
mem_limit: 1g
ports:
- '127.0.0.1:8080:8080'
volumes:
+3
View File
@@ -74,6 +74,9 @@ openai:
terminal: 'unknown'
# 部署选择的 US residency;省略或 null 时使用官方无地域约束的默认分支。
residency: us
# 请求地区覆盖;省略或留空(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'
xai:
+2 -1
View File
@@ -270,7 +270,8 @@ Vue 普通管理请求的错误提示由 `api/request.ts` 响应拦截器统一
规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不重复弹出接口错误;
查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner。
API 模块通过统一的 `RequestOptions` 传递 `signal`、`timeout` 和 `silent`。取消或已被新查询取代的请求
API 模块按 `url`、`method`、`data`(POST)或 `params: data`(GET)排列请求配置;
仅在实际调用方需要时提供 `RequestOptions`(`signal`、`timeout`、`silent`),并放在请求配置末尾。取消或已被新查询取代的请求
不弹提示;后台轮询、重启探测等显式使用 `silent`,它只关闭提示,不吞异常,也不跳过会话失效处理。
批量操作的部分成功汇总、不可逆操作的结果未知等必要业务处理先将对应请求静默,再由业务 owner 提供
一次有上下文的反馈;不得为普通失败重新维护一套消息或业务码映射。
+4 -8
View File
@@ -88,39 +88,35 @@ export function getAccountGroups(data: AccountGroupListParams, options: RequestO
})
}
export function createAccountGroup(data: AccountGroupCreateParam, options: RequestOptions = {}) {
export function createAccountGroup(data: AccountGroupCreateParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/create',
method: 'POST',
data,
...options,
})
}
export function updateAccountGroup(data: AccountGroupUpdateParam, options: RequestOptions = {}) {
export function updateAccountGroup(data: AccountGroupUpdateParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/update',
method: 'POST',
data,
...options,
})
}
export function enableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
export function enableAccountGroup(data: AccountGroupIdParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/enable',
method: 'POST',
data,
...options,
})
}
export function disableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
export function disableAccountGroup(data: AccountGroupIdParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/disable',
method: 'POST',
data,
...options,
})
}
+7 -14
View File
@@ -408,30 +408,27 @@ export function getAccounts(data: AccountListParams, options: RequestOptions = {
})
}
export function exportAccounts(data: AccountExportParam, options: RequestOptions = {}) {
export function exportAccounts(data: AccountExportParam) {
return request<unknown>({
url: '/api/admin/accounts/export',
method: 'GET',
params: data,
...options,
})
}
export function refreshAccount(data: AccountIdParam, options: RequestOptions = {}) {
export function refreshAccount(data: AccountIdParam) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/refresh',
method: 'POST',
data,
...options,
})
}
export function recoverAccount(data: AccountIdParam, options: RequestOptions = {}) {
export function recoverAccount(data: AccountIdParam) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/recover',
method: 'POST',
data,
...options,
})
}
@@ -522,21 +519,19 @@ export function importAccounts(data: AccountImportParam, options: RequestOptions
})
}
export function updateAccount(data: AccountUpdateParam, options: RequestOptions = {}) {
export function updateAccount(data: AccountUpdateParam) {
return request<AccountUpdateResponse>({
url: '/api/admin/accounts/update',
method: 'POST',
data,
...options,
})
}
export function batchUpdateAccounts(data: AccountBatchUpdateParam, options: RequestOptions = {}) {
export function batchUpdateAccounts(data: AccountBatchUpdateParam) {
return request<AccountBatchUpdateResponse>({
url: '/api/admin/accounts/batch-update',
method: 'POST',
data,
...options,
})
}
@@ -549,20 +544,18 @@ export function deleteAccounts(data: AccountDeleteParams, options: RequestOption
})
}
export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) {
export function startAccountOAuth(data: AccountOAuthStartParam) {
return request<AccountOAuthStartResponse>({
url: '/api/admin/accounts/oauth/start',
method: 'POST',
data,
...options,
})
}
export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) {
export function completeAccountOAuth(data: AccountOAuthCompleteParam) {
return request<AccountOAuthCompleteResponse>({
url: '/api/admin/accounts/oauth/complete',
method: 'POST',
data,
...options,
})
}
+6 -12
View File
@@ -87,56 +87,50 @@ export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {})
})
}
export function createApiKey(data: ApiKeyCreateParam, options: RequestOptions = {}) {
export function createApiKey(data: ApiKeyCreateParam) {
return request<ApiKeyCreateResponse>({
url: '/api/admin/client-keys/create',
method: 'POST',
data,
...options,
})
}
export function updateApiKey(data: ApiKeyUpdateParam, options: RequestOptions = {}) {
export function updateApiKey(data: ApiKeyUpdateParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/update',
method: 'POST',
data,
...options,
})
}
export function revealApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function revealApiKey(data: ApiKeyIdParam) {
return request<ApiKeyRevealResponse>({
url: '/api/admin/client-keys/reveal',
method: 'GET',
params: data,
...options,
})
}
export function deleteApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function deleteApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/delete',
method: 'POST',
data,
...options,
})
}
export function disableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function disableApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/disable',
method: 'POST',
data,
...options,
})
}
export function enableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function enableApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/enable',
method: 'POST',
data,
...options,
})
}
+1 -2
View File
@@ -18,12 +18,11 @@ interface LoginParam {
password: string
}
export function login(data: LoginParam, options: RequestOptions = {}) {
export function login(data: LoginParam) {
return request<LoginResponse>({
url: '/api/admin/auth/login',
method: 'POST',
data,
...options,
})
}
+11 -18
View File
@@ -96,71 +96,64 @@ export interface DownloadUrlResult {
expiresInSeconds: number
}
export function getBackupSettings(options: RequestOptions = {}) {
export function getBackupSettings() {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups',
method: 'GET',
...options,
})
}
export function updateBackupStorage(data: UpdateBackupStoragePayload, options: RequestOptions = {}) {
export function updateBackupStorage(data: UpdateBackupStoragePayload) {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups/storage/update',
method: 'POST',
data,
...options,
})
}
export function testBackupStorage(options: RequestOptions = {}) {
export function testBackupStorage() {
return request<ConnectionTestResult>({
url: '/api/admin/settings/backups/storage/test',
method: 'POST',
...options,
})
}
export function updateBackupSchedule(data: UpdateBackupSchedulePayload, options: RequestOptions = {}) {
export function updateBackupSchedule(data: UpdateBackupSchedulePayload) {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups/schedule/update',
method: 'POST',
data,
...options,
})
}
export function getBackupRecords(params: BackupRecordsParams, options: RequestOptions = {}) {
export function getBackupRecords(data: BackupRecordsParams, options: RequestOptions = {}) {
return request<BackupRecordPage>({
url: '/api/admin/settings/backups/records',
method: 'GET',
params,
params: data,
...options,
})
}
export function createBackup(options: RequestOptions = {}) {
export function createBackup() {
return request<BackupRecord>({
url: '/api/admin/settings/backups/create',
method: 'POST',
...options,
})
}
export function getBackupDownloadUrl(backupId: string, options: RequestOptions = {}) {
export function getBackupDownloadUrl(data: { backupId: string }) {
return request<DownloadUrlResult>({
url: '/api/admin/settings/backups/download-url',
method: 'POST',
data: { backupId },
...options,
data,
})
}
export function deleteBackup(backupId: string, options: RequestOptions = {}) {
export function deleteBackup(data: { backupId: string }) {
return request<BackupRecord>({
url: '/api/admin/settings/backups/delete',
method: 'POST',
data: { backupId },
...options,
data,
})
}
+10 -16
View File
@@ -44,21 +44,20 @@ interface ProxyAccountPage {
page: ProxyPage['page']
}
export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
export function getProxyAccounts(data: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
return request<ProxyAccountPage>({
url: '/api/admin/proxies/accounts',
method: 'GET',
params,
params: data,
...options,
})
}
export function removeProxyAccount(data: { proxyId: string, accountId: string }, options: RequestOptions = {}) {
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
return request<{ configRevision: number }>({
url: '/api/admin/proxies/accounts/remove',
method: 'POST',
data,
...options,
})
}
@@ -67,58 +66,53 @@ interface ProxyMutation {
configRevision: number
}
export function getProxies(params: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
export function getProxies(data: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
return request<ProxyPage>({
url: '/api/admin/proxies',
method: 'GET',
params,
params: data,
...options,
})
}
export function createProxy(data: { name: string, proxyUrl: string }, options: RequestOptions = {}) {
export function createProxy(data: { name: string, proxyUrl: string }) {
return request<ProxyMutation>({
url: '/api/admin/proxies/create',
method: 'POST',
data,
...options,
})
}
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }, options: RequestOptions = {}) {
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) {
return request<ProxyMutation>({
url: '/api/admin/proxies/update',
method: 'POST',
data,
...options,
})
}
export function deleteProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
export function deleteProxy(data: { id: string, revision: number }) {
return request<{ configRevision: number }>({
url: '/api/admin/proxies/delete',
method: 'POST',
data,
...options,
})
}
export function testProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
export function testProxy(data: { id: string, revision: number }) {
return request<OutboundProxyRecord>({
url: '/api/admin/proxies/test',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
export function probeProxy(data: { proxyUrl: string }, options: RequestOptions = {}) {
export function probeProxy(data: { proxyUrl: string }) {
return request<OutboundProxyTest>({
url: '/api/admin/proxies/probe',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
+6 -11
View File
@@ -60,44 +60,39 @@ export function getSettings(options: RequestOptions = {}) {
type UpdateSettingsParam = Omit<RuntimeSettings, 'updatedAt'>
export function updateSettings(data: UpdateSettingsParam, options: RequestOptions = {}) {
export function updateSettings(data: UpdateSettingsParam) {
return request<RuntimeSettings>({
url: '/api/admin/settings/update',
method: 'POST',
data,
...options,
})
}
export function getAdminApiKeyStatus(options: RequestOptions = {}) {
export function getAdminApiKeyStatus() {
return request<AdminApiKeyStatus>({
url: '/api/admin/settings/admin-api-key',
method: 'GET',
...options,
})
}
export function regenerateAdminApiKey(options: RequestOptions = {}) {
export function regenerateAdminApiKey() {
return request<RegeneratedAdminApiKey>({
url: '/api/admin/settings/admin-api-key/regenerate',
method: 'POST',
...options,
})
}
export function deleteAdminApiKey(options: RequestOptions = {}) {
export function deleteAdminApiKey() {
return request<DeletedAdminApiKey>({
url: '/api/admin/settings/admin-api-key/delete',
method: 'POST',
...options,
})
}
export function getCodexDesktopWindowsDownloads(refresh = false, options: RequestOptions = {}) {
export function getCodexDesktopWindowsDownloads(data: { refresh?: boolean } = {}) {
return request<CodexDesktopWindowsDownloads>({
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
method: 'GET',
params: refresh ? { refresh: true } : undefined,
...options,
params: data,
})
}
+2 -4
View File
@@ -59,21 +59,19 @@ interface SystemUpdateTarget {
targetVersion?: string
}
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery, options: RequestOptions = {}) {
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) {
return request<SystemUpdateDetail>({
url: '/api/admin/system/update/detail',
method: 'GET',
params: data,
...options,
})
}
export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOptions = {}) {
export function performSystemUpdate(data: SystemUpdateTarget) {
return request<SystemUpdateAccepted>({
url: '/api/admin/system/update',
method: 'POST',
data,
...options,
})
}
@@ -16,7 +16,7 @@ export function useClientDownloads() {
loading.value = true
error.value = ''
try {
downloads.value = await getCodexDesktopWindowsDownloads(refresh)
downloads.value = await getCodexDesktopWindowsDownloads({ refresh })
}
catch (cause: unknown) {
error.value = errorMessage(cause)
@@ -130,7 +130,7 @@ export function useBackupRecords() {
return
downloadStates.value = { ...downloadStates.value, [record.id]: true }
try {
const result = await getBackupDownloadUrl(record.id)
const result = await getBackupDownloadUrl({ backupId: record.id })
// 预签名 URL 带 S3 的 Content-Disposition,临时 anchor 触发浏览器下载即可。
const link = document.createElement('a')
link.href = result.url
@@ -158,7 +158,7 @@ export function useBackupRecords() {
return
deleting.value = true
try {
await deleteBackup(target.id)
await deleteBackup({ backupId: target.id })
toast.success('已请求删除备份')
await paged.execute({ silent: true })
}
+11 -16
View File
@@ -1,24 +1,19 @@
# v3.7.0
## 新增功能
- 账号「个人信息」按需展示 OpenAI 订阅周期、续费状态与计费信息,并汇聚个人资料、累计统计和活动洞察。资料或订阅单项查询失败时,仍保留另一项可用结果。
- 账号列表将凭据有效期明确标为「令牌过期」,与订阅周期区分;订阅仅在打开个人信息或手动刷新时查询,不影响额度刷新和账号调度。
# v3.7.2
## 问题修复
- 修复上游仅返回可用重置次数、未提供卡片明细时无法使用额度重置的问题。现在可确认使用一次重置,由上游选择卡片;结果未知时重试继续复用原幂等标识。
- OpenAI WebSocket 遇到额度耗尽、限流等账号级拒绝时,在允许安全重放的前提下交由调度换号,避免因长 `Retry-After` 在同账号等待。
- OpenAI 明确返回模型容量不足或过载时,在尚未交付输出且允许安全重放的前提下,先进行最多 3 次同账号退避重试,再尝试换号。退避受总尝试次数和请求截止时间约束,容量不足不扣账号健康分、不触发全局熔断。
- OpenAI 用量档位与本地费用估算统一采用最终发送给上游的 `service_tier`,避免请求为 `priority`、响应回显 `default` 时显示及估算为标准档。客户端收到的原始响应保持不变。
- 修复 OpenAI 账号在同一额度窗口内用量回落后仍保持耗尽、无法参与调度的问题。连续两次额度观测确认相关窗口未触顶后可恢复,无需一直等待原重置日期;保留进入新窗口后的原有恢复路径。
- `openai.wire_profile.location` 支持留空、设为 `null` 或省略。未配置时透传客户端原有的 Web Search 地区、环境日期和时区,也不会补写客户端未提供的字段;完整填写时继续按配置覆盖。
## 协议与兼容性
## 发布与部署
- 调整 HTTP / WebSocket 的 TLS 依赖与 WebSocket 握手头部顺序,对齐项目采用的 Codex 客户端传输实现。
- Codex 自动生成的结构化环境上下文使用 `America/New_York` 时区及对应当前日期,网页搜索位置统一为美国 Ohio / Piketon;用户编写的正文和绝对时间戳保持原值。
- Release 附件新增同版本的 `config.example.yaml` 和 `compose.yaml`,并纳入 `checksums.txt` 校验。各平台归档同时包含 `deploy/config.example.yaml`。
- 发布附件中的 Compose 默认固定到该版本镜像,快速开始从同一 Release 下载部署文件,避免混用 `main` 分支配置与已发布镜像。
- 发布说明中的安装、下载与文档说明统一使用中文,部署文档链接固定到对应版本。
- 发版时将版本号与发布说明合并为一次提交,并在该提交上创建 tag。
## 升级说明
- 从 v3.6.1 升级无需新增配置或数据库迁移,前后端应同步更新。
- 管理员接口 `GET /api/admin/accounts/profile-statistics` 替换为 `GET /api/admin/accounts/personal-info`,响应汇聚为 `profile`、`profileError` 和 `subscription`。直接调用旧接口的脚本或自定义管理端需同步调整。
- 档位与费用估算口径仅作用于新记录,不回填历史档位或重算已存储费用;Fast 表示发送档位,不代表已确认上游实际加速,本地估算不能代替官方账单。
- 从 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.0"
version: "3.7.2"