Compare commits

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

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

Validated the rendered Docker Compose configuration.

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

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

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

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

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

---------

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

Validated cargo audit and waiver expiry behavior.

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:25 +08:00
42 changed files with 775 additions and 181 deletions
+10 -1
View File
@@ -40,7 +40,16 @@ jobs:
- name: Run cargo audit
working-directory: backend
run: cargo audit
shell: bash
run: |
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
audit_advisory_args=()
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
fi
cargo audit "${audit_advisory_args[@]}"
frontend-security:
runs-on: ubuntu-latest
+1
View File
@@ -708,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
"serde",
]
[[package]]
+26
View File
@@ -310,6 +310,32 @@ 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_default_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 defaults when omitted");
let custom = original.replace(
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
);
assert_ne!(original, custom);
parse_config(&custom).expect("custom location from YAML");
}
#[test]
fn config_loader_should_reject_invalid_request_location_timezones() {
let original = valid_config();
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
assert_ne!(original, invalid);
assert_rejected(invalid);
}
fn assert_rejected(config: String) {
assert!(parse_config(&config).is_err());
}
@@ -2,7 +2,9 @@
use bytes::Bytes;
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
use gateway_protocol::openai::sse::{
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
};
use serde_json::Value;
use super::error::ResponseEncodeError;
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
#[derive(Debug, Default)]
pub struct OpenAiResponsesEncoder {
response_id: Option<String>,
response_snapshot: Option<Value>,
wire_terminal: Option<Value>,
wire_failure: bool,
}
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
pub const fn new() -> Self {
Self {
response_id: None,
response_snapshot: None,
wire_terminal: None,
wire_failure: false,
}
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
if wire.event_type() == Some("error")
&& let Some(data) = response_failed_sse_data_from_error_event(
self.response_snapshot.as_ref(),
self.response_id.as_deref(),
wire.data(),
)
{
return vec![Bytes::from(encode_sse_event_with_metadata(
"response.failed",
&data.to_string(),
wire.sse_id(),
wire.sse_retry(),
))];
}
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
return vec![raw_sse_frame.clone()];
}
@@ -110,6 +130,16 @@ impl OpenAiResponsesEncoder {
let effective_type = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
if matches!(
effective_type,
Some("response.created" | "response.in_progress" | "response.queued")
) && let Some(response) = wire
.data()
.get("response")
.filter(|value| value.is_object())
{
self.response_snapshot = Some(response.clone());
}
if matches!(
effective_type,
Some("response.completed" | "response.incomplete")
@@ -34,6 +34,7 @@ use gateway_core::event::{
use gateway_core::operation::{Operation, OperationKind};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
);
}
#[tokio::test]
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
),
(
json!({
"type": "response.failed",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
@@ -2,7 +2,7 @@
use std::fmt;
use serde_json::json;
use serde_json::{Value, json};
use thiserror::Error;
/// 单条 SSE 事件。
@@ -277,12 +277,7 @@ pub fn response_failed_sse_data_with_id(
"code": code,
"message": message,
});
let response_id = response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
);
let response_id = response_id_or_generated(response_id);
json!({
"type": "response.failed",
"response": {
@@ -294,6 +289,50 @@ pub fn response_failed_sse_data_with_id(
})
}
/// 将携带嵌套错误的上游事件投影为 OpenAI Responses `response.failed` 数据。
///
/// 该投影供客户端 SSE 兼容转换使用。原始错误对象和事件级元数据保持不透明;
/// 已有 response 快照只更新失败终态字段。
pub fn response_failed_sse_data_from_error_event(
response: Option<&Value>,
response_id: Option<&str>,
error_event: &Value,
) -> Option<Value> {
let mut event = error_event.as_object()?.clone();
let error = event.remove("error").filter(Value::is_object)?;
let mut response = response
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let has_response_id = response
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if !has_response_id {
response.insert(
"id".to_owned(),
Value::String(response_id_or_generated(response_id)),
);
}
response.insert("status".to_owned(), Value::String("failed".to_owned()));
response.insert("error".to_owned(), error);
event.insert(
"type".to_owned(),
Value::String("response.failed".to_owned()),
);
event.insert("response".to_owned(), Value::Object(response));
Some(Value::Object(event))
}
fn response_id_or_generated(response_id: Option<&str>) -> String {
response_id
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
ToString::to_string,
)
}
/// 返回下一个完整 SSE 帧结束位置(含分隔符)。
pub fn sse_frame_end(bytes: &[u8]) -> Option<usize> {
sse_frame_separator_bytes(bytes).map(|(position, separator_len)| position + separator_len)
@@ -1,8 +1,10 @@
use gateway_protocol::openai::sse::{
DONE_SSE_FRAME, MAX_SSE_EVENT_BUFFER_BYTES, SseError, SseEvent, SseEventDecoder,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events, response_failed_sse_event,
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events,
response_failed_sse_data_from_error_event, response_failed_sse_event,
response_failed_sse_event_with_id, sse_frame_end, sse_frame_is_done,
};
use serde_json::json;
fn sse_body_has_done(body: &str) -> bool {
body.trim_end_matches(['\r', '\n'])
@@ -386,6 +388,57 @@ fn response_failed_event_should_preserve_existing_response_id() {
assert_eq!(data["response"]["id"], "resp_existing");
}
#[test]
fn response_failed_projection_should_preserve_response_error_and_event_metadata() {
let response = json!({
"id": "resp_existing",
"model": "gpt-test",
"status": "in_progress",
"future_response_field": {"keep": true}
});
let error_event = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected = response_failed_sse_data_from_error_event(
Some(&response),
Some("ignored_fallback"),
&error_event,
)
.expect("wrapped error event should project");
assert_eq!(
projected,
json!({
"type": "response.failed",
"response": {
"id": "resp_existing",
"model": "gpt-test",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "overloaded",
"param": null,
"future_error_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
})
);
}
#[test]
fn done_frame_detection_should_require_exact_single_data_field() {
assert_eq!(
+1 -1
View File
@@ -13,7 +13,7 @@ async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
chrono-tz = "0.10"
chrono-tz = { version = "0.10", features = ["serde"] }
cookie = "0.18.1"
futures = "0.3.32"
flate2 = "1.1.9"
+26 -1
View File
@@ -8,7 +8,9 @@ use serde::Deserialize;
use url::{Host, Url};
use crate::credential::CodexQuotaRefreshPolicy;
use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState};
use crate::transport::profile::{
CodexRequestLocation, CodexResidency, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
use crate::transport::websocket::CodexWebSocketPoolConfig;
use crate::{
@@ -290,6 +292,8 @@ pub struct CodexWireProfileConfig {
pub terminal: String,
#[serde(default)]
pub residency: Option<CodexResidency>,
#[serde(default)]
pub location: CodexRequestLocation,
pub verified_at: DateTime<Utc>,
}
@@ -305,6 +309,7 @@ impl Default for CodexWireProfileConfig {
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
residency: None,
location: CodexRequestLocation::default(),
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
@@ -331,11 +336,30 @@ impl CodexWireProfileConfig {
("openai.wire_profile.os_version", self.os_version.as_str()),
("openai.wire_profile.arch", self.arch.as_str()),
("openai.wire_profile.terminal", self.terminal.as_str()),
(
"openai.wire_profile.location.region",
self.location.region.as_str(),
),
(
"openai.wire_profile.location.city",
self.location.city.as_str(),
),
] {
if value.trim().is_empty() {
return Err(OpenAiConfigError::InvalidField(field));
}
}
if self.location.country.len() != 2
|| !self
.location
.country
.bytes()
.all(|byte| byte.is_ascii_uppercase())
{
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.location.country",
));
}
if semver::Version::parse(&self.codex_version).is_err() {
return Err(OpenAiConfigError::InvalidField(
"openai.wire_profile.codex_version",
@@ -367,6 +391,7 @@ impl From<CodexWireProfileConfig> for CodexWireProfile {
arch: value.arch,
terminal: value.terminal,
residency: value.residency,
location: value.location,
verified_at: value.verified_at,
}
}
@@ -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: 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,7 @@ 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) else {
return ProviderRequestObservation::default();
};
let semantics = encoded.semantics();
@@ -354,8 +357,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)
.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: 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: &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,41 @@ 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: &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());
align_structured_location_fields(body, Utc::now(), location);
}
fn align_structured_location_fields(body: &mut Map<String, Value>, now: DateTime<Utc>) {
let current_date = now.with_timezone(&New_York).format("%Y-%m-%d").to_string();
fn align_structured_location_fields(
body: &mut Map<String, Value>,
now: DateTime<Utc>,
location: &CodexRequestLocation,
) {
// 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值。
let current_date = now
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
for item in input {
align_environment_context(item, &current_date);
align_environment_context(item, &current_date, location.timezone.name());
}
}
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
for tool in tools {
align_web_search_location(tool);
align_web_search_location(tool, location);
}
}
}
fn align_environment_context(item: &mut Value, current_date: &str) {
fn align_environment_context(item: &mut Value, current_date: &str, timezone: &str) {
let Some(item) = item.as_object_mut() else {
return;
};
@@ -178,13 +186,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 +209,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 +226,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 +238,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,8 +660,9 @@ 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")
.expect("official OpenAI request");
let encoded =
provider_openai::encode_generate_request(&request, "gpt-5.4", &Default::default())
.expect("official OpenAI request");
assert_eq!(
encoded.body().get("model").and_then(Value::as_str),
Some("gpt-5.4")
@@ -1347,6 +1348,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,76 @@ 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_defaults_should_match_the_existing_request_profile() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
assert_eq!(profile.location.country, "US");
assert_eq!(profile.location.region, "Ohio");
assert_eq!(profile.location.city, "Piketon");
assert_eq!(profile.location.timezone.name(), "America/New_York");
}
#[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 = location.clone();
config
.resolve_and_validate(Path::new("/srv/gateway"))
.expect("valid location");
assert_eq!(config.wire_profile_state().snapshot().location, 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 (target, expected) = match field {
"country" => (
&mut config.wire_profile.location.country,
"openai.wire_profile.location.country",
),
"region" => (
&mut config.wire_profile.location.region,
"openai.wire_profile.location.region",
),
_ => (
&mut config.wire_profile.location.city,
"openai.wire_profile.location.city",
),
};
*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 +223,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()
@@ -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",
&Default::default(),
)
.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,61 @@ 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", &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(
@@ -32,7 +87,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", &Default::default())
.expect("encode wire payload");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-routed")));
assert!(encoded.body().get("stream").is_none());
@@ -65,8 +121,8 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity()
};
for input in ["private stable prompt", "different private prompt"] {
let encoded =
encode_generate_request(&request(input), "gpt-routed").expect("encoded request");
let encoded = encode_generate_request(&request(input), "gpt-routed", &Default::default())
.expect("encoded request");
assert!(encoded.local_conversation_id.is_none());
}
}
@@ -87,7 +143,8 @@ 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", &Default::default()).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 +164,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(
Value::Object(encoded.body().clone()),
@@ -170,7 +228,8 @@ 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", &Default::default()).expect("encode");
let after = Utc::now().with_timezone(&New_York);
let encoded = Value::Object(encoded.body().clone());
@@ -241,7 +300,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(encoded.body().get("store"), Some(&json!(true)));
}
@@ -254,7 +314,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(
encoded.body().get("prompt_cache_key"),
@@ -270,7 +331,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(
encoded.client_conversation_id.as_deref(),
@@ -321,7 +383,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(
(
@@ -367,7 +430,8 @@ 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", &Default::default()).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 +472,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("header-turn-state"));
assert_eq!(
@@ -434,7 +499,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(
encoded.downstream_websocket_connection_id.as_deref(),
@@ -464,7 +530,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("body-turn-state"));
assert_eq!(
@@ -489,7 +556,8 @@ 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", &Default::default()).expect("encode");
assert!(encoded.use_websocket);
assert!(!encoded.force_http_sse);
@@ -512,7 +580,8 @@ 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", &Default::default()).expect("encode");
assert!(!encoded.use_websocket);
assert!(encoded.force_http_sse);
@@ -538,7 +607,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", &Default::default())
.expect("opaque options encode");
assert_eq!(
encoded.body().get("provider_options"),
@@ -575,7 +645,8 @@ 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", &Default::default()).expect("encode");
assert_eq!(encoded.responses_lite.as_deref(), Some("true"));
assert_eq!(encoded.memgen_request.as_deref(), Some("true"));
@@ -599,7 +670,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", &Default::default())
.expect("observability request should encode")
.semantics();
@@ -619,7 +690,8 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() {
),
]));
let metadata_encoded =
encode_generate_request(&metadata_request, "gpt-test").expect("encode metadata request");
encode_generate_request(&metadata_request, "gpt-test", &Default::default())
.expect("encode metadata request");
assert_eq!(metadata_encoded.subagent_kind().as_deref(), Some("review"));
let turn_metadata_payload = ProtocolPayload::json_object(
@@ -635,8 +707,9 @@ 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")
.expect("encode turn metadata request");
let turn_metadata_encoded =
encode_generate_request(&turn_metadata_request, "gpt-test", &Default::default())
.expect("encode turn metadata request");
assert_eq!(
turn_metadata_encoded.subagent_kind().as_deref(),
Some("worker")
@@ -669,7 +742,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", &Default::default())
.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()
+5
View File
@@ -48,6 +48,11 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和
模板不重复列出这些默认项。运行后,Provider 检查官方版本并更新运行时请求画像,
不回写 `config.yaml`;检查失败时继续使用上一份有效画像。版本检查不等于重新核验 TLS。
`openai.wire_profile.location` 集中配置请求地区,省略时默认为 `US / Ohio / Piketon / America/New_York`。
自定义时完整填写 `country`(两位大写国家代码)、`region`、`city` 和 `timezone`(IANA 时区);修改后重启生效。
它统一 Responses 的 Web Search 地区与带环境标记的日期、时区,不修改普通聊天内容或 epoch 时间戳,
也不替代 `residency` 约束或随官方版本检查变化。
## 启动
```bash
-2
View File
@@ -86,8 +86,6 @@ services:
redis:
condition: service_healthy
pids_limit: 256
cpus: '2.0'
mem_limit: 1g
ports:
- '127.0.0.1:8080:8080'
volumes:
+2
View File
@@ -74,6 +74,8 @@ openai:
terminal: 'unknown'
# 部署选择的 US residency;省略或 null 时使用官方无地域约束的默认分支。
residency: us
# 请求地区画像;省略 location 时使用以下默认值。时区同时用于环境日期,不改变 epoch 时间戳。
location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }
verified_at: '2026-09-06T11:26:12.084+08:00'
xai:
+2 -1
View File
@@ -270,7 +270,8 @@ Vue 普通管理请求的错误提示由 `api/request.ts` 响应拦截器统一
规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不重复弹出接口错误;
查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner。
API 模块通过统一的 `RequestOptions` 传递 `signal`、`timeout` 和 `silent`。取消或已被新查询取代的请求
API 模块按 `url`、`method`、`data`(POST)或 `params: data`(GET)排列请求配置;
仅在实际调用方需要时提供 `RequestOptions`(`signal`、`timeout`、`silent`),并放在请求配置末尾。取消或已被新查询取代的请求
不弹提示;后台轮询、重启探测等显式使用 `silent`,它只关闭提示,不吞异常,也不跳过会话失效处理。
批量操作的部分成功汇总、不可逆操作的结果未知等必要业务处理先将对应请求静默,再由业务 owner 提供
一次有上下文的反馈;不得为普通失败重新维护一套消息或业务码映射。
+4 -8
View File
@@ -88,39 +88,35 @@ export function getAccountGroups(data: AccountGroupListParams, options: RequestO
})
}
export function createAccountGroup(data: AccountGroupCreateParam, options: RequestOptions = {}) {
export function createAccountGroup(data: AccountGroupCreateParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/create',
method: 'POST',
data,
...options,
})
}
export function updateAccountGroup(data: AccountGroupUpdateParam, options: RequestOptions = {}) {
export function updateAccountGroup(data: AccountGroupUpdateParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/update',
method: 'POST',
data,
...options,
})
}
export function enableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
export function enableAccountGroup(data: AccountGroupIdParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/enable',
method: 'POST',
data,
...options,
})
}
export function disableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
export function disableAccountGroup(data: AccountGroupIdParam) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/disable',
method: 'POST',
data,
...options,
})
}
+7 -14
View File
@@ -408,30 +408,27 @@ export function getAccounts(data: AccountListParams, options: RequestOptions = {
})
}
export function exportAccounts(data: AccountExportParam, options: RequestOptions = {}) {
export function exportAccounts(data: AccountExportParam) {
return request<unknown>({
url: '/api/admin/accounts/export',
method: 'GET',
params: data,
...options,
})
}
export function refreshAccount(data: AccountIdParam, options: RequestOptions = {}) {
export function refreshAccount(data: AccountIdParam) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/refresh',
method: 'POST',
data,
...options,
})
}
export function recoverAccount(data: AccountIdParam, options: RequestOptions = {}) {
export function recoverAccount(data: AccountIdParam) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/recover',
method: 'POST',
data,
...options,
})
}
@@ -522,21 +519,19 @@ export function importAccounts(data: AccountImportParam, options: RequestOptions
})
}
export function updateAccount(data: AccountUpdateParam, options: RequestOptions = {}) {
export function updateAccount(data: AccountUpdateParam) {
return request<AccountUpdateResponse>({
url: '/api/admin/accounts/update',
method: 'POST',
data,
...options,
})
}
export function batchUpdateAccounts(data: AccountBatchUpdateParam, options: RequestOptions = {}) {
export function batchUpdateAccounts(data: AccountBatchUpdateParam) {
return request<AccountBatchUpdateResponse>({
url: '/api/admin/accounts/batch-update',
method: 'POST',
data,
...options,
})
}
@@ -549,20 +544,18 @@ export function deleteAccounts(data: AccountDeleteParams, options: RequestOption
})
}
export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) {
export function startAccountOAuth(data: AccountOAuthStartParam) {
return request<AccountOAuthStartResponse>({
url: '/api/admin/accounts/oauth/start',
method: 'POST',
data,
...options,
})
}
export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) {
export function completeAccountOAuth(data: AccountOAuthCompleteParam) {
return request<AccountOAuthCompleteResponse>({
url: '/api/admin/accounts/oauth/complete',
method: 'POST',
data,
...options,
})
}
+6 -12
View File
@@ -87,56 +87,50 @@ export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {})
})
}
export function createApiKey(data: ApiKeyCreateParam, options: RequestOptions = {}) {
export function createApiKey(data: ApiKeyCreateParam) {
return request<ApiKeyCreateResponse>({
url: '/api/admin/client-keys/create',
method: 'POST',
data,
...options,
})
}
export function updateApiKey(data: ApiKeyUpdateParam, options: RequestOptions = {}) {
export function updateApiKey(data: ApiKeyUpdateParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/update',
method: 'POST',
data,
...options,
})
}
export function revealApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function revealApiKey(data: ApiKeyIdParam) {
return request<ApiKeyRevealResponse>({
url: '/api/admin/client-keys/reveal',
method: 'GET',
params: data,
...options,
})
}
export function deleteApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function deleteApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/delete',
method: 'POST',
data,
...options,
})
}
export function disableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function disableApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/disable',
method: 'POST',
data,
...options,
})
}
export function enableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
export function enableApiKey(data: ApiKeyIdParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/enable',
method: 'POST',
data,
...options,
})
}
+1 -2
View File
@@ -18,12 +18,11 @@ interface LoginParam {
password: string
}
export function login(data: LoginParam, options: RequestOptions = {}) {
export function login(data: LoginParam) {
return request<LoginResponse>({
url: '/api/admin/auth/login',
method: 'POST',
data,
...options,
})
}
+11 -18
View File
@@ -96,71 +96,64 @@ export interface DownloadUrlResult {
expiresInSeconds: number
}
export function getBackupSettings(options: RequestOptions = {}) {
export function getBackupSettings() {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups',
method: 'GET',
...options,
})
}
export function updateBackupStorage(data: UpdateBackupStoragePayload, options: RequestOptions = {}) {
export function updateBackupStorage(data: UpdateBackupStoragePayload) {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups/storage/update',
method: 'POST',
data,
...options,
})
}
export function testBackupStorage(options: RequestOptions = {}) {
export function testBackupStorage() {
return request<ConnectionTestResult>({
url: '/api/admin/settings/backups/storage/test',
method: 'POST',
...options,
})
}
export function updateBackupSchedule(data: UpdateBackupSchedulePayload, options: RequestOptions = {}) {
export function updateBackupSchedule(data: UpdateBackupSchedulePayload) {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups/schedule/update',
method: 'POST',
data,
...options,
})
}
export function getBackupRecords(params: BackupRecordsParams, options: RequestOptions = {}) {
export function getBackupRecords(data: BackupRecordsParams, options: RequestOptions = {}) {
return request<BackupRecordPage>({
url: '/api/admin/settings/backups/records',
method: 'GET',
params,
params: data,
...options,
})
}
export function createBackup(options: RequestOptions = {}) {
export function createBackup() {
return request<BackupRecord>({
url: '/api/admin/settings/backups/create',
method: 'POST',
...options,
})
}
export function getBackupDownloadUrl(backupId: string, options: RequestOptions = {}) {
export function getBackupDownloadUrl(data: { backupId: string }) {
return request<DownloadUrlResult>({
url: '/api/admin/settings/backups/download-url',
method: 'POST',
data: { backupId },
...options,
data,
})
}
export function deleteBackup(backupId: string, options: RequestOptions = {}) {
export function deleteBackup(data: { backupId: string }) {
return request<BackupRecord>({
url: '/api/admin/settings/backups/delete',
method: 'POST',
data: { backupId },
...options,
data,
})
}
+10 -16
View File
@@ -44,21 +44,20 @@ interface ProxyAccountPage {
page: ProxyPage['page']
}
export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
export function getProxyAccounts(data: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
return request<ProxyAccountPage>({
url: '/api/admin/proxies/accounts',
method: 'GET',
params,
params: data,
...options,
})
}
export function removeProxyAccount(data: { proxyId: string, accountId: string }, options: RequestOptions = {}) {
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
return request<{ configRevision: number }>({
url: '/api/admin/proxies/accounts/remove',
method: 'POST',
data,
...options,
})
}
@@ -67,58 +66,53 @@ interface ProxyMutation {
configRevision: number
}
export function getProxies(params: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
export function getProxies(data: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
return request<ProxyPage>({
url: '/api/admin/proxies',
method: 'GET',
params,
params: data,
...options,
})
}
export function createProxy(data: { name: string, proxyUrl: string }, options: RequestOptions = {}) {
export function createProxy(data: { name: string, proxyUrl: string }) {
return request<ProxyMutation>({
url: '/api/admin/proxies/create',
method: 'POST',
data,
...options,
})
}
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }, options: RequestOptions = {}) {
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) {
return request<ProxyMutation>({
url: '/api/admin/proxies/update',
method: 'POST',
data,
...options,
})
}
export function deleteProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
export function deleteProxy(data: { id: string, revision: number }) {
return request<{ configRevision: number }>({
url: '/api/admin/proxies/delete',
method: 'POST',
data,
...options,
})
}
export function testProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
export function testProxy(data: { id: string, revision: number }) {
return request<OutboundProxyRecord>({
url: '/api/admin/proxies/test',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
export function probeProxy(data: { proxyUrl: string }, options: RequestOptions = {}) {
export function probeProxy(data: { proxyUrl: string }) {
return request<OutboundProxyTest>({
url: '/api/admin/proxies/probe',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
+6 -11
View File
@@ -60,44 +60,39 @@ export function getSettings(options: RequestOptions = {}) {
type UpdateSettingsParam = Omit<RuntimeSettings, 'updatedAt'>
export function updateSettings(data: UpdateSettingsParam, options: RequestOptions = {}) {
export function updateSettings(data: UpdateSettingsParam) {
return request<RuntimeSettings>({
url: '/api/admin/settings/update',
method: 'POST',
data,
...options,
})
}
export function getAdminApiKeyStatus(options: RequestOptions = {}) {
export function getAdminApiKeyStatus() {
return request<AdminApiKeyStatus>({
url: '/api/admin/settings/admin-api-key',
method: 'GET',
...options,
})
}
export function regenerateAdminApiKey(options: RequestOptions = {}) {
export function regenerateAdminApiKey() {
return request<RegeneratedAdminApiKey>({
url: '/api/admin/settings/admin-api-key/regenerate',
method: 'POST',
...options,
})
}
export function deleteAdminApiKey(options: RequestOptions = {}) {
export function deleteAdminApiKey() {
return request<DeletedAdminApiKey>({
url: '/api/admin/settings/admin-api-key/delete',
method: 'POST',
...options,
})
}
export function getCodexDesktopWindowsDownloads(refresh = false, options: RequestOptions = {}) {
export function getCodexDesktopWindowsDownloads(data: { refresh?: boolean } = {}) {
return request<CodexDesktopWindowsDownloads>({
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
method: 'GET',
params: refresh ? { refresh: true } : undefined,
...options,
params: data,
})
}
+2 -4
View File
@@ -59,21 +59,19 @@ interface SystemUpdateTarget {
targetVersion?: string
}
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery, options: RequestOptions = {}) {
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) {
return request<SystemUpdateDetail>({
url: '/api/admin/system/update/detail',
method: 'GET',
params: data,
...options,
})
}
export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOptions = {}) {
export function performSystemUpdate(data: SystemUpdateTarget) {
return request<SystemUpdateAccepted>({
url: '/api/admin/system/update',
method: 'POST',
data,
...options,
})
}
@@ -16,7 +16,7 @@ export function useClientDownloads() {
loading.value = true
error.value = ''
try {
downloads.value = await getCodexDesktopWindowsDownloads(refresh)
downloads.value = await getCodexDesktopWindowsDownloads({ refresh })
}
catch (cause: unknown) {
error.value = errorMessage(cause)
@@ -130,7 +130,7 @@ export function useBackupRecords() {
return
downloadStates.value = { ...downloadStates.value, [record.id]: true }
try {
const result = await getBackupDownloadUrl(record.id)
const result = await getBackupDownloadUrl({ backupId: record.id })
// 预签名 URL 带 S3 的 Content-Disposition,临时 anchor 触发浏览器下载即可。
const link = document.createElement('a')
link.href = result.url
@@ -158,7 +158,7 @@ export function useBackupRecords() {
return
deleting.value = true
try {
await deleteBackup(target.id)
await deleteBackup({ backupId: target.id })
toast.success('已请求删除备份')
await paged.execute({ silent: true })
}
+7 -15
View File
@@ -1,24 +1,16 @@
# v3.7.0
# v3.7.1
## 新增功能
- 账号「个人信息」按需展示 OpenAI 订阅周期、续费状态与计费信息,并汇聚个人资料、累计统计和活动洞察。资料或订阅单项查询失败时,仍保留另一项可用结果。
- 账号列表将凭据有效期明确标为「令牌过期」,与订阅周期区分;订阅仅在打开个人信息或手动刷新时查询,不影响额度刷新和账号调度。
- OpenAI 请求地区可通过 `openai.wire_profile.location` 统一配置国家、地区、城市和 IANA 时区,并同时用于 Web Search 地区与结构化环境日期。省略该配置时继续使用现有默认地区。
## 问题修复
- 修复上游仅返回可用重置次数、未提供卡片明细时无法使用额度重置的问题。现在可确认使用一次重置,由上游选择卡片;结果未知时重试继续复用原幂等标识。
- OpenAI WebSocket 遇到额度耗尽、限流等账号级拒绝时,在允许安全重放的前提下交由调度换号,避免因长 `Retry-After` 在同账号等待。
- OpenAI 明确返回模型容量不足或过载时,在尚未交付输出且允许安全重放的前提下,先进行最多 3 次同账号退避重试,再尝试换号。退避受总尝试次数和请求截止时间约束,容量不足不扣账号健康分、不触发全局熔断。
- OpenAI 用量档位与本地费用估算统一采用最终发送给上游的 `service_tier`,避免请求为 `priority`、响应回显 `default` 时显示及估算为标准档。客户端收到的原始响应保持不变。
## 协议与兼容性
- 调整 HTTP / WebSocket 的 TLS 依赖与 WebSocket 握手头部顺序,对齐项目采用的 Codex 客户端传输实现。
- Codex 自动生成的结构化环境上下文使用 `America/New_York` 时区及对应当前日期,网页搜索位置统一为美国 Ohio / Piketon;用户编写的正文和绝对时间戳保持原值。
- 修复 OpenAI WebSocket 上游在流中返回 `error` 事件时,SSE 客户端只能看到无原因断流的问题。网关现在将其转换为 `response.failed`,保留上游的错误类型、代码、消息和响应 ID,便于客户端识别并决定是否重试。
- 移除默认 Compose 部署中固定的 2 CPU 与 1 GiB 容器资源限制,允许服务在 CPU 少于 2 核的主机上启动;实际资源上限由部署环境决定。
## 升级说明
- 从 v3.6.1 升级无需新增配置或数据库迁移,前后端应同步更新。
- 管理员接口 `GET /api/admin/accounts/profile-statistics` 替换为 `GET /api/admin/accounts/personal-info`,响应汇聚为 `profile`、`profileError` 和 `subscription`。直接调用旧接口的脚本或自定义管理端需同步调整。
- 档位与费用估算口径仅作用于新记录,不回填历史档位或重算已存储费用;Fast 表示发送档位,不代表已确认上游实际加速,本地估算不能代替官方账单。
- 从 v3.7.0 升级无需数据库迁移,已有配置可以继续使用,`openai.wire_profile.location` 为可选项。
- 使用新版 `deploy/config.example.yaml` 中的 `openai.wire_profile.location` 时,需要同步升级到 v3.7.1 镜像;v3.7.0 镜像会因严格配置校验拒绝该字段。
- 需要解除默认 CPU 和内存限制的部署应同步更新 `deploy/compose.yaml`。自行设置的宿主机或容器资源限制仍然生效。
+1 -1
View File
@@ -1 +1 @@
version: "3.7.0"
version: "3.7.1"