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 | ||
|
|
040a1e82a6 | ||
|
|
4f39bfc5a8 | ||
|
|
3e3616863d | ||
|
|
1bbae67040 | ||
|
|
fc07ac0803 | ||
|
|
c7823a7821 | ||
|
|
61d1bcb405 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -708,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, ¤t_date);
|
||||
align_environment_context(item, ¤t_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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 提供
|
||||
一次有上下文的反馈;不得为普通失败重新维护一套消息或业务码映射。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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.0"
|
||||
version: "3.7.2"
|
||||
|
||||
Reference in New Issue
Block a user