mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
040a1e82a6 | ||
|
|
4f39bfc5a8 | ||
|
|
3e3616863d | ||
|
|
1bbae67040 | ||
|
|
fc07ac0803 | ||
|
|
c7823a7821 | ||
|
|
61d1bcb405 |
@@ -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
|
||||
|
||||
Generated
+1
@@ -708,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,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, ¤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 +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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
+7
-15
@@ -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 @@
|
||||
version: "3.7.0"
|
||||
version: "3.7.1"
|
||||
|
||||
Reference in New Issue
Block a user