mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8fdc2b5cd | ||
|
|
dfabf0f9dd | ||
|
|
817b0f2d6d | ||
|
|
a975868046 | ||
|
|
cc2dfab19a |
@@ -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 之后的本地文件不会改变该次构建。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'release/version.yaml'
|
||||
- 'release/notes.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 @@
|
||||
version: "3.7.1"
|
||||
version: "3.7.2"
|
||||
|
||||
Reference in New Issue
Block a user