Compare commits

...
15 Commits
Author SHA1 Message Date
ZyyandCodex e7e5b659cd chore(release): v3.31.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:31:19 +08:00
ZyyandCodex b225b3732d fix(accounts): unify Codex labels and tighten plan badge spacing
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:28:06 +08:00
ZyyandCodex 9dca763ea4 fix(ui): polish plugin management and API key labels
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 16:57:42 +08:00
ZyyandCodex d26bc36db5 chore(release): v3.31.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:42 +08:00
ZyyandCodex 96d5ab674a feat(ui): refine forms, navigation and theme loading
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:34 +08:00
ZyyandCodex 84c0db2613 fix(ui): simplify token label and center reasoning effort
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:58:06 +08:00
ZyyandCodex d819315d8b fix(ci): allow independent UI dependency versions
Keep frozen installs, source compatibility builds, and lockfile checks
without requiring every consumer to pin the UI submodule revision.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:48:28 +08:00
ZyyandCodex 06e1d8f0ac chore(release): v3.31.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:40:32 +08:00
ZyyandCodex bdc8274b36 chore(ui): upgrade component library to v0.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:38:49 +08:00
ZyyandCodex 3f6607209b refactor(usage): remove reported model tracking and polish UI
Remove upstream response model tracking across providers, storage, APIs,
and plugin facts without migrating historical records.

Refine sidebar hints, locale loading indicators, model columns, and usage
page labels and filters.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:03:27 +08:00
ZyyandCodex 3c05bc8f07 refactor(plugins): remove provider-specific request parameters
Use request, attempt, and outbound middleware for plugin-owned identity
selection and complete request rewriting. Preserve native account identity
and partition WebSocket connections through existing outbound opening changes.

Remove the construction parameter channel and its obsolete tests and docs.
Keep middleware at v6 without a compatibility shim.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 03:30:52 +08:00
ZyyandCodex 9ccd84f1a0 test(store): fix pg_dump lifecycle probe on Ubuntu 26.04
Redirect the mock process stdout to /dev/null explicitly so the wait-stage probe works when sleep reopens closed standard descriptors. Preserve cancellation, process reaping, and partial archive cleanup assertions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:35:59 +08:00
ZyyandCodex a8f183707e chore(build): refresh base images and pin Ubuntu runners
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:02:46 +08:00
ZyyandCodex 3f20af61ff chore(plugins): update workbench to v0.7.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:23:55 +08:00
ZyyandCodex efaed4c3a6 fix(web): refine health loading and quota labels
Hide request health granularity until analytics are loaded and remove the placeholder daily granularity. Use compact uppercase quota badges and localized five-hour labels.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:08:42 +08:00
160 changed files with 708 additions and 1631 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ permissions:
jobs:
container:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 45
env:
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
+8 -5
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
backend:
if: inputs.run_backend
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
@@ -34,7 +34,7 @@ jobs:
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
services:
postgres:
image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
env:
POSTGRES_DB: codex_proxy
POSTGRES_USER: codex_proxy
@@ -48,7 +48,7 @@ jobs:
--health-timeout 5s
--health-retries 10
redis:
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
image: redis:8-alpine@sha256:0bf6577d7774e592b29d093e3ac9390eb91e13d72c21e506afce736de5e0eeb0
ports:
- 6379:6379
options: >-
@@ -199,7 +199,7 @@ jobs:
frontend:
if: inputs.run_frontend
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
@@ -213,7 +213,7 @@ jobs:
uses: ./.github/actions/build-frontend
workflow-lint:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Checkout
@@ -223,6 +223,8 @@ jobs:
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
# https://github.com/rhysd/actionlint/issues/657
# GitHub 已提供 ubuntu-26.04,actionlint 尚未收录该标签
# https://github.com/actions/runner-images#available-images
- name: Lint GitHub Actions workflows
run: >-
docker run --rm
@@ -231,6 +233,7 @@ jobs:
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
-ignore '^label "ubuntu-26\.04" is unknown\.'
.github/workflows/*.yml
- name: Check workflow script syntax
+2 -12
View File
@@ -20,7 +20,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
permissions:
contents: read
@@ -105,7 +105,7 @@ jobs:
source-development:
needs: changes
if: needs.changes.outputs.source == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
steps:
- name: Checkout with development modules
@@ -129,16 +129,6 @@ jobs:
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
if [ "$locked_revision" != "$ui_revision" ]; then
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
exit 1
fi
done
- name: Install development dependencies
run: |
pnpm --dir modules/ui install --frozen-lockfile
+4 -4
View File
@@ -21,7 +21,7 @@ env:
jobs:
release-info:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 10
outputs:
tag: ${{ steps.version.outputs.tag }}
@@ -73,7 +73,7 @@ jobs:
frontend:
needs: release-info
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
@@ -205,7 +205,7 @@ jobs:
package-assets:
needs: [release-info, frontend, build-docker-binary, build-binary]
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
contents: read
@@ -375,7 +375,7 @@ jobs:
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
contents: write
+1 -1
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
guard:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
+2 -2
View File
@@ -19,7 +19,7 @@ concurrency:
jobs:
backend-security:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -38,7 +38,7 @@ jobs:
cargo-audit audit --file backend/Cargo.lock
frontend-security:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
steps:
- name: Checkout
@@ -740,7 +740,6 @@ pub struct UsageListRecord {
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
@@ -802,7 +801,6 @@ pub struct UsageRecord {
pub upstream_transport: Option<String>,
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明
pub provider_metadata_json: Option<String>,
@@ -1361,7 +1361,6 @@ fn total_record(
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
upstream_response_model: None,
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
@@ -28,13 +28,12 @@ pub(super) fn build(document: &Value) -> io::Result<Vec<u8>> {
let detail = &evidence["detail"]["request"];
writeln!(
archive,
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={},上游报告={}\n传输:{} → {}\n时延:{}",
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={}\n传输:{} → {}\n时延:{}",
detail["logicalOutcome"],
detail["statusCode"],
detail["attemptCount"],
detail["requestedModel"],
detail["upstreamModel"],
detail["upstreamResponseModel"],
detail["clientTransport"],
detail["upstreamTransport"],
detail["latencyDetails"]
@@ -279,7 +279,6 @@ pub(crate) fn usage_list_record_view(
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
@@ -377,7 +376,6 @@ pub(crate) fn usage_record_view(
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
status_code,
client_transport: record.client_transport,
@@ -103,7 +103,6 @@ pub struct UsageListRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
@@ -142,7 +141,6 @@ pub struct UsageRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub client_transport: String,
@@ -764,7 +764,6 @@ pub(super) fn usage_record_with_account(
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: None,
upstream_response_model: None,
service_tier: None,
provider_metadata_json: None,
attempt_count: 1,
@@ -909,7 +908,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
upstream_response_model: Some("grok-4.6-build".to_owned()),
service_tier: Some("default".to_owned()),
input_tokens: Some(1),
output_tokens: Some(1),
@@ -1059,7 +1057,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
"upstreamModel": value["data"]["items"][0]["upstreamModel"],
"upstreamResponseModel": value["data"]["items"][0]["upstreamResponseModel"],
"reasoningEffort": value["data"]["items"][0]["reasoningEffort"],
"reasoningPreset": value["data"]["items"][0]["reasoningPreset"],
"subagentKind": value["data"]["items"][0]["subagentKind"],
@@ -1083,7 +1080,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": 9,
"requestedModel": "grok-4.5",
"upstreamModel": "grok-4.5",
"upstreamResponseModel": "grok-4.6-build",
"reasoningEffort": "max",
"reasoningPreset": "ultra",
"subagentKind": "worker",
@@ -110,7 +110,6 @@ fn usage_record() -> UsageListRecord {
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
upstream_response_model: Some("private-sentinel".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
input_tokens: Some(1000),
output_tokens: Some(100),
@@ -2302,12 +2302,6 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: None,
@@ -2490,12 +2484,6 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: Some(finalization.error),
@@ -104,7 +104,6 @@ impl fmt::Debug for MiddlewareHeader {
#[derive(Clone, PartialEq, Eq)]
pub struct MiddlewareRequest {
settings: Option<RequestSettings>,
provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
protocol: String,
headers: Vec<MiddlewareHeader>,
body: Bytes,
@@ -126,7 +125,6 @@ impl MiddlewareRequest {
let protocol = protocol.into();
Self {
settings: None,
provider_parameters: None,
original_protocol: protocol.clone(),
original_body: body.clone(),
capabilities: None,
@@ -147,27 +145,6 @@ impl MiddlewareRequest {
self.settings.as_ref()
}
/// Provider 提供的本次构造参数;Core 不解释其中的业务字段
#[must_use]
pub fn provider_parameters(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
self.provider_parameters.as_ref()
}
#[must_use]
pub fn with_provider_parameters(
mut self,
parameters: serde_json::Map<String, serde_json::Value>,
) -> Self {
self.provider_parameters = Some(parameters);
self
}
pub fn take_provider_parameters(
&mut self,
) -> Option<serde_json::Map<String, serde_json::Value>> {
self.provider_parameters.take()
}
#[must_use]
pub fn protocol(&self) -> &str {
&self.protocol
@@ -755,7 +755,6 @@ impl AttemptContext {
provider: ProviderKind,
model: Option<String>,
account_id: ProviderAccountId,
parameters: Option<serde_json::Map<String, serde_json::Value>>,
terminal: provider::ProviderMiddlewareTerminal,
) -> Result<provider::ProviderStream, ProviderError> {
// 入口已校验原请求,此处复查中间件最终提交的 operation,每次重试均生效
@@ -763,7 +762,7 @@ impl AttemptContext {
.account_scope()
.is_some_and(|scope| !scope.allows_image_generation())
{
Box::new(move |operation, headers, parameters| {
Box::new(move |operation, headers| {
Box::pin(async move {
image_generation::check_image_generation(false, &operation).map_err(|_| {
ProviderError::new(
@@ -772,7 +771,7 @@ impl AttemptContext {
)
.with_retry_prohibited()
})?;
terminal(operation, headers, parameters).await
terminal(operation, headers).await
})
})
} else {
@@ -790,7 +789,6 @@ impl AttemptContext {
context,
operation,
self.request.client_transport,
parameters,
terminal,
)
.await
@@ -1072,7 +1070,6 @@ pub struct ModelRequestFinalization {
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub service_tier: Option<String>,
pub upstream_response_model: Option<String>,
/// Provider 已筛选的专有观测 JSON;Core 不解释字段
pub provider_metadata_json: Option<String>,
/// 请求全程的有界诊断快照,跨 Provider 与重试保留
@@ -249,7 +249,6 @@ pub struct RequestObservation {
provider: Option<ProviderKind>,
account_id: Option<ProviderAccountId>,
upstream_model: Option<crate::routing::UpstreamModelId>,
response_model: Option<String>,
service_tier: Option<String>,
outcome: RequestObservationOutcome,
send_state: UpstreamSendState,
@@ -287,7 +286,6 @@ impl RequestObservation {
provider: None,
account_id: None,
upstream_model: None,
response_model: None,
service_tier: None,
outcome,
send_state,
@@ -341,11 +339,6 @@ impl RequestObservation {
self.upstream_model.as_ref()
}
#[must_use]
pub fn response_model(&self) -> Option<&str> {
self.response_model.as_deref()
}
#[must_use]
pub fn service_tier(&self) -> Option<&str> {
self.service_tier.as_deref()
@@ -626,7 +619,6 @@ impl RequestObservationDispatch {
outcome: RequestObservationOutcome::Rejected,
account_id: None,
upstream_model: None,
response_model: None,
service_tier: None,
send_state: UpstreamSendState::NotSent,
attempt_count: 0,
@@ -699,7 +691,6 @@ impl RequestObservationDispatch {
provider,
account_id: attempt.map(|attempt| attempt.provider_account_id().clone()),
upstream_model: attempt.and_then(|attempt| attempt.upstream_model().cloned()),
response_model: finalization.upstream_response_model.clone(),
service_tier: finalization.service_tier.clone(),
outcome,
send_state: finalization.send_state,
@@ -471,7 +471,6 @@ pub type ProviderMiddlewareTerminal = Box<
dyn FnOnce(
Operation,
Vec<MiddlewareHeader>,
Option<serde_json::Map<String, serde_json::Value>>,
) -> BoxFuture<'static, Result<ProviderStream, ProviderError>>
+ Send
+ 'static,
@@ -490,19 +489,18 @@ fn provider_middleware_next(
activate: oneshot::Receiver<()>,
observation: Arc<Mutex<MiddlewareSendObservation>>,
) -> MiddlewareNext {
crate::middleware::compose(Vec::new(), move |mut request| {
crate::middleware::compose(Vec::new(), move |request| {
Box::pin(async move {
let fallback_protocol = operation.protocol().to_owned();
if request.has_capability_declaration() {
return Err(MiddlewareError::InvalidState);
}
let parameters = request.take_provider_parameters();
let (protocol, headers, body) = request.into_parts();
validate_attempt_headers(&headers)?;
let operation = operation
.replace_middleware_wire(protocol, body)
.map_err(|_| MiddlewareError::InvalidState)?;
let stream = terminal(operation, headers, parameters)
let stream = terminal(operation, headers)
.await
.map_err(MiddlewareError::Provider)?;
let metadata = stream.metadata().clone();
@@ -736,22 +734,18 @@ pub async fn execute_attempt_middleware(
context: MiddlewareContext,
operation: Operation,
transport: ClientTransport,
parameters: Option<serde_json::Map<String, serde_json::Value>>,
terminal: ProviderMiddlewareTerminal,
) -> Result<ProviderStream, ProviderError> {
let Some(plan) = plan else {
return terminal(operation, Vec::new(), parameters).await;
return terminal(operation, Vec::new()).await;
};
let mut request = MiddlewareRequest::new(
let request = MiddlewareRequest::new(
operation.protocol(),
Vec::<MiddlewareHeader>::new(),
operation
.middleware_body()
.map_err(|_| middleware_protocol_error(UpstreamSendState::NotSent))?,
);
if let Some(parameters) = parameters {
request = request.with_provider_parameters(parameters);
}
let (prepared, metadata) = oneshot::channel();
let (activate, activation) = oneshot::channel();
let observation = Arc::new(Mutex::new(MiddlewareSendObservation {
-17
View File
@@ -624,7 +624,6 @@ pub struct ProviderResponseObservation {
status_code: Option<u16>,
request_id: Option<OpaqueUpstreamValue>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
timings: ProviderResponseTimings,
// 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合
client_headers: Arc<[ProviderResponseHeader]>,
@@ -641,7 +640,6 @@ impl ProviderResponseObservation {
status_code: None,
request_id: None,
service_tier: None,
upstream_response_model: None,
timings: ProviderResponseTimings::default(),
client_headers: Arc::default(),
provider_metadata: None,
@@ -697,21 +695,6 @@ impl ProviderResponseObservation {
self
}
/// 记录 Provider 从上游响应明确取得的模型,不改写发送模型或计费合同
#[must_use]
pub fn with_upstream_response_model_if_valid(mut self, model: &str) -> Self {
let model = model.trim();
if validate_text(model, 256, false, None).is_ok() {
self.upstream_response_model = Some(model.to_owned());
}
self
}
#[must_use]
pub fn upstream_response_model(&self) -> Option<&str> {
self.upstream_response_model.as_deref()
}
#[must_use]
pub const fn with_timings(mut self, timings: ProviderResponseTimings) -> Self {
self.timings = timings;
@@ -491,7 +491,6 @@ struct FinalState {
http_version: Option<String>,
websocket_pool: Option<String>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
upstream_request_id: Option<String>,
upstream_status_code: Option<u16>,
transport_decision_wait_ms: Option<u64>,
@@ -682,7 +681,6 @@ impl ExecutionStore for FakeStore {
http_version: finalization.http_version,
websocket_pool: finalization.websocket_pool,
service_tier: finalization.service_tier,
upstream_response_model: finalization.upstream_response_model,
upstream_request_id: finalization.upstream_request_id,
upstream_status_code: finalization.upstream_status_code,
transport_decision_wait_ms: finalization.timings.transport_decision_wait_ms,
@@ -1764,7 +1762,6 @@ fn response_observation_is_persisted_but_never_delivered() {
.with_websocket_pool(WebSocketPoolKind::New)
.with_status_code(200)
.with_request_id(OpaqueUpstreamValue::new("upstream-observed"))
.with_upstream_response_model_if_valid("gpt-returned")
.try_with_service_tier("priority")
.expect("service tier")
.with_timings(ProviderResponseTimings {
@@ -1804,10 +1801,6 @@ fn response_observation_is_persisted_but_never_delivered() {
assert_eq!(finalization.http_version.as_deref(), Some("HTTP/2"));
assert_eq!(finalization.websocket_pool.as_deref(), Some("new"));
assert_eq!(finalization.service_tier.as_deref(), Some("priority"));
assert_eq!(
finalization.upstream_response_model.as_deref(),
Some("gpt-returned")
);
assert_eq!(
finalization.upstream_request_id.as_deref(),
Some("upstream-observed")
@@ -2083,7 +2076,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
.with_http_version(UpstreamHttpVersion::Http11)
.with_status_code(503)
.with_request_id(OpaqueUpstreamValue::new("discarded-request"))
.with_upstream_response_model_if_valid("discarded-model")
.with_timings(ProviderResponseTimings {
transport_decision_wait_ms: Some(7),
connect_ms: Some(11),
@@ -2146,7 +2138,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
let state = store.state.lock().expect("store lock");
let finalization = &state.finalizations[0];
assert_eq!(finalization.upstream_response_model, None);
let trace: Value =
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
let events = trace["events"].as_array().unwrap();
@@ -2282,7 +2282,6 @@ impl Provider for ObservationProvider {
gateway_core::event::ProviderResponseObservation::new(
UpstreamTransport::new("http_sse").unwrap(),
)
.with_upstream_response_model_if_valid("gpt-reported")
.with_service_tier_if_valid("priority"),
);
Ok(ProviderStream::new(
@@ -2615,7 +2614,6 @@ fn final_observation_is_emitted_once_for_success_rejection_and_detached_cancella
.map(UpstreamModelId::as_str),
Some("gpt-start")
);
assert_eq!(observations[0].response_model(), Some("gpt-reported"));
assert_eq!(observations[0].service_tier(), Some("priority"));
assert_eq!(
observations[0].outcome(),
@@ -103,8 +103,7 @@ impl Provider for PreparingProvider {
provider,
model,
account,
None,
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
let events = futures::stream::once(async move {
Err::<ProviderEvent, _>(
@@ -88,7 +88,7 @@ fn plan(action: Action) -> FrozenMiddlewarePlan {
}
fn terminal(polls: Arc<AtomicUsize>, released: Arc<AtomicUsize>) -> ProviderMiddlewareTerminal {
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
let metadata = ProviderCallMetadata::new(
ProviderKind::new("openai").unwrap(),
@@ -127,7 +127,6 @@ fn inspecting_a_response_waits_for_the_outer_stream_and_keeps_send_facts() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal(polls.clone(), released.clone()),
)
.await
@@ -166,7 +165,6 @@ fn rejecting_metadata_drops_the_unactivated_handler_and_account_lease() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal(polls.clone(), released.clone()),
)
.await
@@ -196,8 +194,7 @@ fn middleware_rejection_before_next_does_not_wait_for_activation() {
context(),
operation(),
ClientTransport::HttpJson,
None,
Box::new(|_, _, _| panic!("rejected middleware cannot prepare upstream")),
Box::new(|_, _| panic!("rejected middleware cannot prepare upstream")),
)
.await
.err()
@@ -340,7 +340,7 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
let terminal: ProviderMiddlewareTerminal = {
let terminal_calls = Arc::clone(&terminal_calls);
Box::new(move |operation, headers, _| {
Box::new(move |operation, headers| {
terminal_calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
assert!(headers.is_empty());
@@ -392,7 +392,6 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal,
)
.await
@@ -487,7 +486,7 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() {
payload.clone(),
)
.unwrap();
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| {
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
empty_provider_stream().metadata().clone(),
@@ -501,7 +500,6 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() {
context(),
operation(),
transport,
None,
terminal,
)
.await
@@ -569,7 +567,7 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() {
Some(10),
)
.unwrap();
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| {
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
empty_provider_stream().metadata().clone(),
@@ -584,7 +582,6 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() {
context(),
operation(),
ClientTransport::HttpSse,
None,
terminal,
)
.await
@@ -599,7 +596,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
let allowed_calls = Arc::new(AtomicUsize::new(0));
let allowed_terminal: ProviderMiddlewareTerminal = {
let allowed_calls = Arc::clone(&allowed_calls);
Box::new(move |_operation, headers, _| {
Box::new(move |_operation, headers| {
allowed_calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
assert_eq!(headers.len(), 2);
@@ -627,7 +624,6 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
context(),
operation(),
ClientTransport::HttpJson,
None,
allowed_terminal,
)
.await
@@ -637,7 +633,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
let calls = Arc::new(AtomicUsize::new(0));
let terminal: ProviderMiddlewareTerminal = {
let calls = calls.clone();
Box::new(move |_, headers, _| {
Box::new(move |_, headers| {
calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
assert_eq!(headers[0].name(), "authorization");
@@ -661,7 +657,6 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal,
)
.await
@@ -741,8 +736,7 @@ fn attempt_middleware_cannot_add_image_generation_to_a_denied_scope() {
ProviderKind::new("openai").unwrap(),
Some("gpt-test".into()),
ProviderAccountId::new("acct_middleware").unwrap(),
None,
Box::new(move |operation, _, _| {
Box::new(move |operation, _| {
Box::pin(async move {
assert!(operation.image_generation_requested());
count.fetch_add(1, Ordering::SeqCst);
@@ -98,7 +98,6 @@ pub(super) async fn invoke(
scope,
)?;
let head = MiddlewareRequestHead {
provider_parameters: invocation.provider_parameters(),
settings_sources: invocation.settings_sources(),
settings: invocation
.settings()
@@ -455,7 +455,6 @@ fn wire_observation(observation: &RequestObservation) -> RequestCompleted {
upstream_model: observation
.upstream_model()
.map(|model| model.as_str().to_owned()),
response_model: observation.response_model().map(str::to_owned),
service_tier: observation.service_tier().map(str::to_owned),
requested_model: observation
.requested_model()
@@ -47,12 +47,6 @@ pub(super) fn merge(
.try_with_service_tier(value)
.map_err(|source| invalid(sent).with_source(source))?;
}
if let Some(value) = update.upstream_response_model {
current = current.with_upstream_response_model_if_valid(&value);
if current.upstream_response_model() != Some(value.trim()) {
return Err(invalid(sent));
}
}
if !update.client_headers.is_empty() {
current = current.with_client_headers(
update
@@ -119,7 +119,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
status_code: value.status_code(),
request_id: value.request_id().map(|value| value.as_str().to_owned()),
service_tier: value.service_tier().map(str::to_owned),
upstream_response_model: value.upstream_response_model().map(str::to_owned),
timings: facts::ResponseTimings {
transport_decision_wait_ms: timings.transport_decision_wait_ms,
connect_ms: timings.connect_ms,
@@ -135,15 +135,6 @@ pub(crate) enum MiddlewareCompletionBody {
}
impl MiddlewareInvocation {
pub(crate) fn provider_parameters(&self) -> Option<serde_json::Map<String, serde_json::Value>> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.original_request
.provider_parameters()
.cloned()
}
pub(crate) fn settings(&self) -> Result<serde_json::Value, PluginFault> {
let state = self
.state
@@ -343,12 +334,6 @@ impl MiddlewareInvocation {
request: MiddlewareNextRequest,
payload: Vec<u8>,
) -> Result<MiddlewareRequest, PluginFault> {
if let Some(parameters) = request.provider_parameters {
if original.provider_parameters().is_none() {
return Err(invalid());
}
original = original.with_provider_parameters(parameters);
}
if let Some(settings) = request.settings {
let current = original.settings().ok_or_else(|| {
PluginFault::new(
@@ -71,8 +71,7 @@ async fn reading_websocket_facts_preserves_original_text_and_opaque_messages() {
attempt_middleware_context_for_transport(Arc::default(), ClientTransport::WebSocket),
operation(),
ClientTransport::WebSocket,
None,
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
ProviderCallMetadata::new(
@@ -190,7 +189,6 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
.with_status_code(200)
.with_request_id(OpaqueUpstreamValue::new("response-request-id"))
.with_service_tier_if_valid("priority")
.with_upstream_response_model_if_valid("returned-model")
.with_client_headers(vec![ProviderResponseHeader::new(
"set-cookie",
Bytes::from_static(b"fixture=secret"),
@@ -228,8 +226,7 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
attempt_middleware_context(Arc::default()),
operation(),
ClientTransport::HttpJson,
None,
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
metadata,
@@ -3,72 +3,6 @@
use super::*;
use gateway_admin::ports::plugins::PluginPreparation;
#[tokio::test]
async fn provider_parameters_compose_through_sdk_without_merging_deleted_fields() {
let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap();
let package = crate::support::package_with_contributions(
&worker,
Contributions::from([crate::support::contribution(
Capability::Middleware,
vec![Stage::Attempt],
vec!["openai".into()],
vec!["openai".into()],
)]),
);
let original = serde_json::json!({"installation_id":"account-default", "future":{"keep":true}});
let first = serde_json::json!({"installation_id":"plugin-first"});
let last = serde_json::json!({});
let instances = [
("parameters-first", original.clone(), first.clone()),
("parameters-last", first, serde_json::Value::Null),
]
.into_iter()
.enumerate()
.map(|(index, (id, expected, parameters))| InstanceFixture {
id,
configuration: serde_json::json!({"mode":"parameters", "attempt":true,
"expected_parameters":expected, "parameters":parameters}),
bindings: vec![binding(
MIDDLEWARE_CONTRIBUTION,
"attempt",
index as i32,
PluginFailurePolicy::Reject,
)],
})
.collect();
let (cache, runtime) = setup_package(instances, package).await;
let generation = prepare(&runtime).await;
let plan = runtime
.execution_registry()
.middleware(&generation)
.unwrap();
let downstream = Downstream {
calls: Arc::default(),
closes: Arc::default(),
reads: Arc::default(),
error: false,
};
let response = plan
.handle(
attempt_middleware_context(Arc::default()),
budget_request().with_provider_parameters(original.as_object().unwrap().clone()),
gateway_core::middleware::compose(Vec::new(), move |request| {
assert_eq!(
request.provider_parameters().unwrap(),
last.as_object().unwrap()
);
downstream.into_next().run(request)
}),
)
.await
.unwrap();
let (_, _, _, body, _) = response.into_parts();
body.close().await;
drop(plan);
drop(generation);
super::super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn partial_update_preserves_inflight_middleware_and_uses_new_configuration_for_new_calls() {
let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([
@@ -667,7 +667,7 @@ async fn adapter_preserves_observation_updates_and_measures_only_real_output() {
{"event":{"facts":[{"type":"started","id":"response-one","model":"native-model"},
{"type":"content_added","index":0,"kind":"reasoning"}]},
"observation":{"transport":"http_sse","http_version":"HTTP/2","status_code":200,
"request_id":"upstream-494","upstream_response_model":"real-model",
"request_id":"upstream-494",
"client_headers":[{"name":"x-bytes","value":[255]},{"name":"x-bytes","value":[254]}],
"provider_metadata":"{ \"future\": 1e3 }","timings":{"connect_ms":5,"headers_ms":8}}},
{"event":{"facts":[{"type":"reasoning_delta","index":0,"text":""}]}},
@@ -711,7 +711,6 @@ async fn adapter_preserves_observation_updates_and_measures_only_real_output() {
assert_eq!(last.status_code(), Some(200));
assert_eq!(last.http_version().unwrap().as_str(), "HTTP/2");
assert_eq!(last.request_id().unwrap().as_str(), "upstream-494");
assert_eq!(last.upstream_response_model(), Some("real-model"));
assert_eq!(last.service_tier(), Some("priority"));
assert_eq!(
last.provider_metadata().unwrap().as_json(),
@@ -1359,7 +1358,7 @@ async fn cancelling_adapter_body_closes_pending_websocket_and_releases_selected_
}
#[tokio::test]
async fn usage_without_response_model_still_uses_native_account_pricing() {
async fn upstream_adapter_usage_uses_native_account_pricing() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
@@ -545,19 +545,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
call.request.head.settings = settings.clone();
}
}
if mode == "parameters" {
assert_eq!(
serde_json::to_value(&call.request.head.provider_parameters).unwrap(),
configuration["expected_parameters"],
);
if let Some(parameters) = configuration.get("parameters") {
call.request.head.provider_parameters = if parameters.is_null() {
None
} else {
Some(parameters.as_object().unwrap().clone())
};
}
}
if mode == "inspect" && !call.request.body.is_empty() {
// 读取和解析只能作用于副本,不能把解析结果写回未改写的正文
assert!(serde_json::from_slice::<serde_json::Value>(&call.request.body).is_ok());
@@ -1046,7 +1046,6 @@ impl Peer {
id,
NEXT_METHOD,
serde_json::to_value(MiddlewareNextRequest {
provider_parameters: None,
settings: None,
protocol: None,
header_mutations: Vec::new(),
@@ -402,7 +402,12 @@ Ping 的自动 Pong 与关闭握手属于底层 WebSocket 协议事实;插件
所有外层处理结束后,Provider 才把结果交给 transport 发送。插件错误、非法传输值或正文超限在原生发送前结束,
不自动跳过、重放或切换传输;取消、请求截止与回调资源沿用当前 attempt
身份策略可在 `request` / `attempt` 读取原始输入,以真实 Key 与账号标识调用私有状态服务维护映射,
再在 `outbound` 自行改写完整请求中的身份及关联字段。UUID 生成、并发创建、持久化、会话与缓存标识派生均由插件负责;
通用中间件层不解释 installation ID、device ID 等策略字段,也不在插件输出后重新派生业务字段
WebSocket 的 `body` 是准备发送的一条完整 UTF-8 文本消息,`headers` 和 `uri` 是候选 opening。
新响应链按出站改写产生的 URI / 握手头差异分区,精确续接仍使用持有原响应链的连接。
复用连接时不能改写已发生的握手;宿主不为插件主动重连、重放消息或重建续接。
插件需要握手身份与消息内容一致时,应结合实际连接与续接语义选择改写
@@ -429,17 +434,6 @@ request 与 attempt 阶段通过 `append_header` 添加的请求头会进入上
OpenAI 与 xAI 的上游 headers 先由账号和画像生成,插件同名字段覆盖基线,同名多值按插件顺序保留;不存在按认证、Cookie 或会话名称划分的保护列表。
实际账号租约、凭据版本与计费归属仍记录真实选中事实,不随插件修改 header 名称而伪造
attempt 的 `request.head.provider_parameters` 携带 Provider 提供的本次构造参数;没有该能力的执行不提供此对象。
OpenAI 原生 Responses、Images、Search 与 Live 创建请求提供 `installation_id` 字符串,默认取自选中账号,
用于原生安装身份、已有 turn metadata 身份字段及会话、缓存关联标识的派生。独立 `upstream_adapter` 自行构造其请求。
插件直接修改此对象,SDK 在值改变时提交完整替换;空对象明确删除全部参数,将原本存在的对象设为 `None` 同样提交空对象。
线协议的 `next.provider_parameters` 省略时保留原值,显式 `null` 或非对象类型拒绝。
后续插件读取前层结果,Core / Runtime 不解释业务字段、不深合并;缺失必需字段或类型错误在发送前失败且禁止重试,不补回账号默认身份。
身份选择、Key 与账号配对、UUID 生成与持久化由插件负责,可复用已有状态服务;参数不写回账号凭据、全局设置或真实计费事实。
WS 新响应链按构造身份区分连接,精确续接仍使用实际持有该响应链的原连接;Live sideband / hangup 不继承创建请求的临时参数
request 阶段 `request.head.settings` 是本次调用的有效设置对象,包含 `runtime`、`fast_mode`、`client_limits` 和 `timeout_ms`。
`fast_mode` 使用 `default`、`enabled`、`disabled` 三态,具体档位语义见[账号分组](../../../../../docs/api.md#6-账号分组)。
`runtime` 使用宿主快照的设置字段,画像已合并 Client Key 设置;可以修改映射、画像、调度、位置及计价等执行参数。
@@ -70,9 +70,6 @@ pub struct MiddlewareRequestHead {
/// 当前请求的完整有效执行设置;尚未建立可改写设置的边界使用 null
#[serde(default)]
pub settings: serde_json::Value,
/// 本次 Provider 构造参数;仅原生 attempt 提供
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
pub request_id: String,
pub client_key_id: String,
pub account_group_ids: Vec<String>,
@@ -131,13 +128,6 @@ pub struct MiddlewareNextRequest {
deserialize_with = "deserialize_replacement"
)]
pub settings: Option<serde_json::Value>,
/// 完整替换构造参数;空对象仍表示显式替换,不隐式合并或补回删除字段
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_replacement"
)]
pub provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
#[serde(default)]
@@ -72,7 +72,6 @@ pub struct ResponseObservation {
pub status_code: Option<u16>,
pub request_id: Option<String>,
pub service_tier: Option<String>,
pub upstream_response_model: Option<String>,
#[serde(default)]
pub timings: ResponseTimings,
#[serde(default)]
@@ -119,7 +118,6 @@ impl ResponseObservation {
status_code: None,
request_id: None,
service_tier: None,
upstream_response_model: None,
timings: ResponseTimings::default(),
client_headers: Vec::new(),
provider_metadata: None,
@@ -187,9 +187,6 @@ pub struct RequestCompleted {
/// 本次实际选中账号使用的上游模型,未进入上游时可能不存在
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_model: Option<String>,
/// 上游响应报告的模型,与请求中的模型名称分开保存
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -39,7 +39,6 @@ pub struct MiddlewareRequest {
pub body: Vec<u8>,
original_protocol: String,
original_settings: serde_json::Value,
original_provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
original_headers: Vec<MiddlewareHeader>,
original_body: Vec<u8>,
body_replaced: bool,
@@ -99,9 +98,6 @@ impl MiddlewareRequest {
};
(
MiddlewareNextRequest {
provider_parameters: (self.head.provider_parameters
!= self.original_provider_parameters)
.then(|| self.head.provider_parameters.unwrap_or_default()),
settings: (self.head.settings != self.original_settings)
.then_some(self.head.settings),
protocol,
@@ -345,7 +341,6 @@ impl RequestCall {
context: call.context,
request: MiddlewareRequest {
original_settings: head.settings.clone(),
original_provider_parameters: head.provider_parameters.clone(),
body: call.payload,
original_body,
body_replaced: false,
@@ -13,37 +13,6 @@ use gateway_plugin_sdk::{
};
use serde_json::json;
#[test]
fn provider_parameters_distinguish_preserve_empty_and_invalid_replacement() {
let preserved: MiddlewareNextRequest =
serde_json::from_value(json!({"body":"preserve"})).unwrap();
assert!(preserved.provider_parameters.is_none());
for replacement in [json!({}), json!({"installation_id":null})] {
let request: MiddlewareNextRequest = serde_json::from_value(json!({
"body":"preserve", "provider_parameters":replacement,
}))
.unwrap();
assert_eq!(
request.provider_parameters.as_ref(),
replacement.as_object()
);
}
for invalid in [
json!(null),
json!([]),
json!("invalid"),
json!(42),
json!(false),
] {
assert!(
serde_json::from_value::<MiddlewareNextRequest>(json!({
"body":"preserve", "provider_parameters":invalid,
}))
.is_err()
);
}
}
#[test]
fn settings_replacement_preserves_explicit_null_for_host_validation() {
let preserved: MiddlewareNextRequest =
@@ -75,7 +44,6 @@ fn middleware_capability_and_mounts_have_one_stable_wire_vocabulary() {
#[test]
fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() {
let request = MiddlewareRequestHead {
provider_parameters: None,
settings_sources: serde_json::Value::Null,
settings: serde_json::Value::Null,
client_key_id: "fixture-key".into(),
@@ -113,7 +81,6 @@ fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() {
#[test]
fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() {
let preserve = MiddlewareNextRequest {
provider_parameters: None,
settings: None,
protocol: None,
header_mutations: Vec::new(),
@@ -126,7 +93,6 @@ fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers()
);
let replace = MiddlewareNextRequest {
provider_parameters: None,
settings: None,
protocol: Some("xai".into()),
header_mutations: vec![
@@ -64,7 +64,6 @@ fn observation() -> RequestCompleted {
client_key_id: Some("key_fixture".into()),
account_id: Some("acct_fixture".into()),
upstream_model: Some("upstream-model".into()),
response_model: Some("reported-model".into()),
service_tier: Some("default".into()),
provider: None,
completed_at_ms: 1234,
@@ -114,19 +113,12 @@ fn observation_exposes_selected_and_reported_facts_without_inventing_values() {
assert_eq!(wire["client_key_id"], "key_fixture");
assert_eq!(wire["account_id"], "acct_fixture");
assert_eq!(wire["upstream_model"], "upstream-model");
assert_eq!(wire["response_model"], "reported-model");
assert_eq!(wire["service_tier"], "default");
request.account_id = None;
request.upstream_model = None;
request.response_model = None;
request.service_tier = None;
let wire = serde_json::to_value(request).unwrap();
for field in [
"account_id",
"upstream_model",
"response_model",
"service_tier",
] {
for field in ["account_id", "upstream_model", "service_tier"] {
assert!(wire.get(field).is_none(), "unknown {field} must be omitted");
}
}
@@ -391,7 +391,6 @@ fn middleware_context(id: u64) -> CallContext {
pub(super) fn middleware_request() -> MiddlewareRequestHead {
MiddlewareRequestHead {
provider_parameters: None,
settings_sources: serde_json::Value::Null,
settings: serde_json::Value::Null,
client_key_id: "fixture-key".into(),
@@ -1,4 +1,4 @@
//! 从 OpenAI 事件与响应头提取模型、用量、额度和重试等待事实
//! 从 OpenAI 事件与响应头提取用量、额度和重试等待事实
use std::{collections::BTreeMap, time::Duration};
@@ -7,56 +7,6 @@ use serde_json::Value;
use super::sse::{SseError, parse_sse_events};
/// 单次 Responses 流明确声明的模型;终态优先,缺失时不使用请求模型补齐
#[derive(Debug, Default)]
pub struct ResponseModelObservation {
model: Option<String>,
terminal: bool,
}
impl ResponseModelObservation {
/// 从原始事件读取响应模型,避免工具转换或 canonical 兜底改写观测事实
pub fn observe(&mut self, event_type: Option<&str>, value: &Value) {
let terminal = matches!(
event_type,
Some(
"response.completed"
| "response.done"
| "response.incomplete"
| "response.failed"
| "response.cancelled"
| "response.canceled"
)
);
if self.terminal || (self.model.is_some() && !terminal) {
return;
}
let Some(model) = value
.pointer("/response/model")
.and_then(Value::as_str)
.and_then(observed_model_name)
else {
return;
};
self.model = Some(model.to_owned());
self.terminal = terminal;
}
/// 返回原始响应声明,未声明时保持未知
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
}
/// 规范化可展示的上游模型名,非法值不影响响应交付
#[must_use]
pub fn observed_model_name(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control))
.then_some(value)
}
/// 从 Codex/OpenAI usage 结构中提取出的标准化 token 用量
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -7,50 +7,6 @@ use gateway_protocol::openai::events::{
};
use serde_json::json;
#[test]
fn response_model_observation_keeps_first_declaration_until_terminal() {
use gateway_protocol::openai::events::ResponseModelObservation;
let mut observed = ResponseModelObservation::default();
observed.observe(
Some("response.created"),
&json!({"response": {"model": " first "}}),
);
observed.observe(
Some("response.in_progress"),
&json!({"response": {"model": "intermediate"}}),
);
assert_eq!(observed.model(), Some("first"));
observed.observe(
Some("response.completed"),
&json!({"response": {"model": "final"}}),
);
observed.observe(
Some("response.in_progress"),
&json!({"response": {"model": "late"}}),
);
assert_eq!(observed.model(), Some("final"));
assert_eq!(ResponseModelObservation::default().model(), None);
}
#[test]
fn response_model_observation_ignores_missing_and_invalid_values() {
use gateway_protocol::openai::events::ResponseModelObservation;
for response in [
json!({}),
json!({"model": null}),
json!({"model": 42}),
json!({"model": " "}),
json!({"model": "a\nb"}),
json!({"model": "a".repeat(257)}),
] {
let mut observed = ResponseModelObservation::default();
observed.observe(Some("response.completed"), &json!({"response": response}));
assert_eq!(observed.model(), None);
}
}
#[test]
fn billable_usage_should_validate_cache_writes_totals_and_overflow() {
for (raw, expected) in [
@@ -248,7 +248,6 @@ pub struct ModelRequestFinalization {
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub service_tier: Option<String>,
pub upstream_response_model: Option<String>,
pub provider_metadata_json: Option<Value>,
pub diagnostic_trace_json: Option<Value>,
pub error_kind: Option<String>,
@@ -314,12 +313,6 @@ impl ModelRequestFinalization {
return Err(invalid("service tier is invalid"));
}
}
if let Some(model) = self.upstream_response_model.as_deref() {
require_nonempty(ENTITY, "upstream_response_model", model)?;
if model.len() > 256 || model.chars().any(char::is_control) {
return Err(invalid("upstream response model is invalid"));
}
}
if self
.provider_metadata_json
.as_ref()
@@ -1180,7 +1173,6 @@ impl ExecutionStore for PgExecutionStore {
http_version: finalization.http_version,
websocket_pool: finalization.websocket_pool,
service_tier: finalization.service_tier,
upstream_response_model: finalization.upstream_response_model,
provider_metadata_json,
diagnostic_trace_json: finalization
.diagnostic_trace_json
@@ -21,8 +21,6 @@ pub(super) struct RequestObservation {
#[serde(skip_serializing_if = "Option::is_none")]
transport: Option<TransportObservation>,
#[serde(skip_serializing_if = "Option::is_none")]
response: Option<ResponseObservation>,
#[serde(skip_serializing_if = "Option::is_none")]
continuation: Option<ContinuationObservation>,
}
@@ -146,13 +144,6 @@ struct ConnectionObservation {
idle_ms: Option<u64>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct ResponseObservation {
#[serde(skip_serializing_if = "Option::is_none")]
response_model: Option<String>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct ContinuationObservation {
@@ -263,9 +254,6 @@ impl RequestObservation {
idle_ms: value.upstream_connection_idle_ms,
}),
}),
response: Some(ResponseObservation {
response_model: value.upstream_response_model.clone(),
}),
error: Some(ErrorObservation {
provider_error_code: value.provider_error_code.clone(),
message: value.error_message.clone(),
@@ -849,7 +849,6 @@ impl ExecutionObservationWrite {
finalization.http_version.as_deref(),
finalization.websocket_pool.as_deref(),
finalization.service_tier.as_deref(),
finalization.upstream_response_model.as_deref(),
finalization.provider_metadata_json.as_deref(),
finalization.diagnostic_trace_json.as_deref(),
finalization.provider_error_code.as_deref(),
@@ -329,7 +329,6 @@ pub(crate) fn usage_list_record_from_row(
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
upstream_response_model: get(row, "upstream_response_model")?,
service_tier: get(row, "service_tier")?,
input_tokens: optional_unsigned(row, "input_tokens")?,
output_tokens: optional_unsigned(row, "output_tokens")?,
@@ -404,7 +403,6 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
upstream_transport: get(row, "upstream_transport")?,
http_version: get(row, "http_version")?,
websocket_pool: get(row, "websocket_pool")?,
upstream_response_model: get(row, "upstream_response_model")?,
service_tier: get(row, "service_tier")?,
provider_metadata_json: get::<Option<serde_json::Value>>(row, "provider_observation_json")?
.map(|value| serde_json::to_string(&value))
@@ -146,7 +146,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
mr.provider_account_plan_type_snapshot as provider_account_plan_type,
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
mr.upstream_model_id, mr.upstream_transport, mr.service_tier,
mr.input_tokens, mr.output_tokens, mr.cached_input_tokens, mr.cache_write_input_tokens,
mr.reasoning_output_tokens, mr.image_input_tokens, mr.image_output_tokens,
mr.total_tokens, mr.billing_snapshot_json, mr.cost_source, mr.cost_amount::text, mr.cost_currency,
@@ -171,7 +171,7 @@ pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str =
mr.provider_account_authentication_kind_snapshot
as provider_account_authentication_kind,
mr.upstream_model_id, mr.upstream_transport, mr.http_version, mr.websocket_pool,
mr.service_tier, mr.upstream_response_model, mr.provider_observation_json, mr.diagnostic_trace_json,
mr.service_tier, mr.provider_observation_json, mr.diagnostic_trace_json,
mr.attempt_count, mr.upstream_send_state, mr.downstream_committed_at,
mr.outcome, mr.client_status_code, mr.upstream_status_code,
mr.client_response_id, mr.upstream_request_id, mr.upstream_response_id,
@@ -22,7 +22,7 @@ async fn dump_process_lifecycle() {
let script = directory.path().join("pg_dump");
std::fs::write(
&script,
"#!/bin/sh\nprintf '%s' $$ > \"$CPR_TEST_PG_DUMP_PROBE_DIR/pid\"\ncase \"$5\" in\n wait) exec 1>&-; exec sleep 30 ;;\n stream) printf archive; exec sleep 30 ;;\n fail) printf partial; exit 1 ;;\n *) printf archive ;;\nesac\n",
"#!/bin/sh\nprintf '%s' $$ > \"$CPR_TEST_PG_DUMP_PROBE_DIR/pid\"\ncase \"$5\" in\n wait) exec >/dev/null; exec sleep 30 ;;\n stream) printf archive; exec sleep 30 ;;\n fail) printf partial; exit 1 ;;\n *) printf archive ;;\nesac\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
@@ -119,8 +119,13 @@ async fn dump_process_lifecycle() {
.await;
let pid = std::fs::read_to_string(directory.join("pid")).unwrap();
if mode == "wait" {
// stdout 已关闭,但子进程仍存活:取消必须覆盖 wait 阶段
wait_until(|| std::fs::read_link(format!("/proc/{pid}/fd/1")).is_err()).await;
// stdout 已脱离管道,但子进程仍存活:取消必须覆盖 wait 阶段
// sleep 可能重开关闭的标准 fd,因此以显式重定向确认管道已关闭
wait_until(|| {
std::fs::read_link(format!("/proc/{pid}/fd/1"))
.is_ok_and(|target| target == Path::new("/dev/null"))
})
.await;
}
if abort {
task.abort();
@@ -407,7 +407,6 @@ async fn core_adapter_should_persist_calculated_cost_exactly() {
finalization.downstream_committed_at = Some(std::time::SystemTime::now());
finalization.websocket_pool = Some("reuse".to_owned());
finalization.service_tier = Some("priority".to_owned());
finalization.upstream_response_model = Some("grok-4.6-build".to_owned());
finalization.cost = CalculatedCost::from_usd_ticks(12_345)
.expect("calculated cost")
.into_estimate();
@@ -452,10 +451,6 @@ async fn core_adapter_should_persist_calculated_cost_exactly() {
detail.request.upstream_model_id.as_deref(),
Some("grok-4.5")
);
assert_eq!(
detail.request.upstream_response_model.as_deref(),
Some("grok-4.6-build")
);
let page = observation
.list_usage_records(UsageRecordQuery {
status: Default::default(),
@@ -471,10 +466,6 @@ async fn core_adapter_should_persist_calculated_cost_exactly() {
.await
.expect("request list");
assert_eq!(page.items.len(), 1);
assert_eq!(
page.items[0].upstream_response_model.as_deref(),
Some("grok-4.6-build")
);
database.close().await;
}
@@ -668,7 +659,6 @@ fn successful_core_finalization(id: &str) -> CoreModelRequestFinalization {
upstream_transport: Some("websocket".to_owned()),
http_version: Some("HTTP/2".to_owned()),
websocket_pool: None,
upstream_response_model: None,
service_tier: None,
provider_metadata_json: None,
error: None,
@@ -1589,7 +1579,6 @@ pub(super) fn early_failure(request: &CoreNewModelRequest) -> CoreModelRequestFi
upstream_transport: None,
http_version: None,
websocket_pool: None,
upstream_response_model: None,
service_tier: None,
provider_metadata_json: None,
diagnostic_trace_json: trace.snapshot().map(|value| value.to_string()),
+4 -4
View File
@@ -1774,7 +1774,7 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
"OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试",
),
},
Error::InvalidInput => (Kind::Invalid, "OpenAI 账号输入格式不合法,请检查导入内容"),
Error::InvalidInput => (Kind::Invalid, "Codex 账号输入格式不合法,请检查导入内容"),
Error::InvalidCredential => (
Kind::Invalid,
"OpenAI 凭据不完整或格式无效,请检查凭据或重新授权",
@@ -1784,8 +1784,8 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
Kind::Invalid,
refresh_rejection_message(code.as_deref()).unwrap_or("刷新令牌已失效,请重新授权"),
),
Error::AccountBanned { .. } => (Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"),
Error::NotFound => (Kind::NotFound, "OpenAI 账号不存在,请刷新账号列表"),
Error::AccountBanned { .. } => (Kind::Invalid, "Codex 账号已被停用,请检查账号状态"),
Error::NotFound => (Kind::NotFound, "Codex 账号不存在,请刷新账号列表"),
Error::RefreshLeaseUnavailable => {
(Kind::Conflict, "令牌刷新繁忙,请等待当前刷新完成后重试")
}
@@ -1920,7 +1920,7 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
"OpenAI 额度查询凭据无效,请检查账号授权",
None,
),
Error::NotFound => (Kind::NotFound, "OpenAI 额度查询账号不存在", None),
Error::NotFound => (Kind::NotFound, "Codex 额度查询账号不存在", None),
Error::RevisionConflict => (
Kind::Conflict,
"账号凭据已被更新,请刷新账号列表后重试",
@@ -179,8 +179,7 @@ impl CodexProvider {
provider_kind,
requested_model.map(|model| model.as_str().to_owned()),
account_id,
Some(RequestParameters::baseline(lease.installation_id())),
Box::new(move |operation, middleware_headers, parameters| {
Box::new(move |operation, middleware_headers| {
Box::pin(async move {
provider
.execute_selected_raw_json_endpoint(
@@ -188,11 +187,8 @@ impl CodexProvider {
operation,
middleware_headers,
request,
SelectedRequest {
parameters: RequestParameters::decode(parameters)?,
lease,
account_selection_wait_ms,
},
lease,
account_selection_wait_ms,
)
.await
})
@@ -207,13 +203,9 @@ impl CodexProvider {
operation: Operation,
middleware_headers: Vec<MiddlewareHeader>,
mut request: RawJsonEndpointRequest,
selected: SelectedRequest,
mut lease: CodexCredentialLease,
account_selection_wait_ms: u64,
) -> Result<ProviderStream, ProviderError> {
let SelectedRequest {
parameters,
mut lease,
account_selection_wait_ms,
} = selected;
let affinity = match &operation {
Operation::GenerateImage(image) => {
let model = endpoint_requested_model(image.payload());
@@ -337,7 +329,7 @@ impl CodexProvider {
lease.capacity_snapshot(),
));
// Standalone Provider 端点没有可证明的账号 owner;Images/Search metadata 必须按
// 跨账号输入收敛到本次构造参数,不能沿用下游声明的账号身份
// 跨账号输入收敛到当前 lease,不能沿用下游声明的账号或 installation identity
let turn_metadata = payload
.context()
.get("turn_metadata")
@@ -345,12 +337,11 @@ impl CodexProvider {
.and_then(|metadata| {
crate::transport::request::scope_turn_metadata(
metadata,
&parameters.installation_id,
lease.installation_id(),
true,
)
});
let events = cold_json_response_stream(ColdJsonResponse {
parameters,
client: self
.client_for_request(&context)?
.for_account(lease.account())
@@ -392,7 +383,6 @@ struct RawJsonEndpointRequest {
}
pub(super) struct ColdResponse {
pub(super) parameters: RequestParameters,
pub(super) client: CodexBackendClient,
pub(super) response_origin: Url,
pub(super) request: CodexResponsesRequest,
@@ -412,7 +402,6 @@ pub(super) struct ColdResponse {
}
pub(super) struct ColdJsonResponse {
pub(super) parameters: RequestParameters,
pub(super) client: CodexBackendClient,
pub(super) response_origin: Url,
pub(super) endpoint_path: &'static str,
@@ -657,7 +646,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
let response = create_json_attempt(
&request,
&active_account,
&request.parameters.installation_id,
request.lease.installation_id(),
&authorization,
cookie_header.as_ref(),
account_selection,
@@ -776,7 +765,6 @@ fn image_response_metering(
pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
let ColdResponse {
parameters,
client,
response_origin,
request,
@@ -833,7 +821,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
&request,
&request_id,
&active_account,
&parameters.installation_id,
lease.installation_id(),
&authorization,
cookie_header.as_ref(),
account_selection,
@@ -978,7 +966,6 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
let mut decoder = CodexCanonicalDecoder::new(upstream_model.as_str())
.with_trace(trace.clone())
.with_pricing(context.pricing().get("openai").and_then(|models| models.get(upstream_model.as_str())).cloned())
.with_reported_model(response.response_metadata.effective_model.as_deref())
.with_requested_service_tier(request.service_tier())
.with_request_tool_pricing(upstream_model.as_str(), request.tools())
.with_raw_sse_passthrough();
@@ -1037,7 +1024,6 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
&mut decoder,
)
.await;
let observation_event = if rate_limits_changed || metadata_merge.is_some()
@@ -1115,12 +1101,9 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
&mut decoder,
)
.await;
let metadata_changed = metadata_merge.unwrap_or(false);
let response_model_changed = observation_state
.observe_upstream_response_model(decoder.response_model());
let service_tier_changed = observation_state
.observe_upstream_service_tier(decoder.response_service_tier());
let terminal_failure = canonical_failure.map(|(error, semantic_output_seen)| {
@@ -1208,7 +1191,6 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
}));
}
if (rate_limits_changed
|| response_model_changed
|| service_tier_changed
|| timing_changed
|| metadata_changed
@@ -1278,8 +1260,6 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
)
});
let timing_signals = decoder.take_timing_signals();
let response_model_changed = observation_state
.observe_upstream_response_model(decoder.response_model());
let service_tier_changed = observation_state
.observe_upstream_service_tier(decoder.response_service_tier());
let output_timing_changed = observation_state
@@ -1314,7 +1294,6 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
&mut decoder,
)
.await
.unwrap_or(false);
@@ -1340,8 +1319,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
.await;
}
if (response_model_changed
|| service_tier_changed
if (service_tier_changed
|| timing_changed
|| rate_limits_changed
|| metadata_changed
@@ -1383,26 +1361,10 @@ async fn merge_response_metadata_updates(
updates: Option<&CodexResponseMetadataUpdates>,
session_capture: &mut Option<OpenAiSessionCapture>,
observation_state: &mut OpenAiResponseObservationState,
decoder: &mut CodexCanonicalDecoder,
) -> Option<bool> {
let updates = updates?;
let mut pending = updates.lock().await;
let turn_state = pending.turn_state.take();
let reported_model = pending.reported_model.clone();
drop(pending);
if turn_state.is_none() && reported_model.is_none() {
return None;
let turn_state = updates?.lock().await.turn_state.take()?;
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
let mut changed = false;
if let Some(turn_state) = turn_state {
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
changed |= observation_state.merge_client_header("x-codex-turn-state", &turn_state);
}
if let Some(model) = reported_model {
decoder.observe_reported_model(&model);
changed |= observation_state.observe_upstream_response_model(decoder.response_model());
}
Some(changed)
Some(observation_state.merge_client_header("x-codex-turn-state", &turn_state))
}
@@ -280,8 +280,7 @@ impl CodexProvider {
provider_kind,
Some(upstream_model.as_str().to_owned()),
account_id,
Some(RequestParameters::baseline(lease.installation_id())),
Box::new(move |operation, middleware_headers, parameters| {
Box::new(move |operation, middleware_headers| {
Box::pin(async move {
provider
.execute_selected_live_call(
@@ -289,11 +288,8 @@ impl CodexProvider {
operation,
upstream_model,
middleware_headers,
SelectedRequest {
parameters: RequestParameters::decode(parameters)?,
lease,
account_selection_wait_ms,
},
lease,
account_selection_wait_ms,
)
.await
})
@@ -308,13 +304,9 @@ impl CodexProvider {
operation: Operation,
upstream_model: UpstreamModelId,
middleware_headers: Vec<MiddlewareHeader>,
selected: SelectedRequest,
mut lease: CodexCredentialLease,
account_selection_wait_ms: u64,
) -> Result<ProviderStream, ProviderError> {
let SelectedRequest {
parameters,
mut lease,
account_selection_wait_ms,
} = selected;
let Operation::ProviderHttp(request) = operation else {
return Err(provider_error(
ProviderErrorKind::InvalidRequest,
@@ -374,7 +366,6 @@ impl CodexProvider {
.cloned()
.collect::<Vec<_>>();
let events = cold_live_call_stream(ColdLiveCall {
parameters,
client: self
.client_for_request(&context)?
.for_account(lease.account())
@@ -407,7 +398,6 @@ impl CodexProvider {
}
struct ColdLiveCall {
parameters: RequestParameters,
client: CodexBackendClient,
registry: Arc<CodexLiveRegistry>,
response_origin: Url,
@@ -451,7 +441,7 @@ fn cold_live_call_stream(request: ColdLiveCall) -> EventStream {
authorization.expose_secret(),
active_account.upstream_account_id(),
&request_id,
Some(&request.parameters.installation_id),
Some(request.lease.installation_id()),
);
request_context.trace = Some(&trace);
request_context.account_selection = CodexAccountSelectionTelemetry::new(
@@ -107,7 +107,6 @@ mod execution;
mod failure;
mod live;
mod observation;
mod parameters;
mod upstream_adapter;
mod workers;
pub(crate) use live::{CodexLiveGateway, CodexLiveRegistry};
@@ -118,7 +117,6 @@ use execution::*;
pub use failure::openai_failure_affects_account_score;
use failure::*;
use observation::*;
use parameters::RequestParameters;
pub(crate) use workers::worker_contributions;
const PROVIDER_NAME: &str = "openai";
@@ -221,14 +219,9 @@ impl PreparedGenerateRequest {
}
struct SelectedGenerate {
request: SelectedRequest,
frozen_requirements: CapabilityRequirements,
}
struct SelectedRequest {
lease: CodexCredentialLease,
account_selection_wait_ms: u64,
parameters: RequestParameters,
frozen_requirements: CapabilityRequirements,
}
impl CodexProvider {
@@ -756,10 +749,7 @@ impl Provider for CodexProvider {
provider_kind,
Some(upstream_model.as_str().to_owned()),
account_id,
adapter
.is_none()
.then(|| RequestParameters::baseline(lease.installation_id())),
Box::new(move |operation, middleware_headers, parameters| {
Box::new(move |operation, middleware_headers| {
Box::pin(async move {
if let Some(adapter) = adapter {
return provider.execute_upstream_adapter(
@@ -780,11 +770,8 @@ impl Provider for CodexProvider {
terminal_model,
terminal_context,
SelectedGenerate {
request: SelectedRequest {
parameters: RequestParameters::decode(parameters)?,
lease,
account_selection_wait_ms,
},
lease,
account_selection_wait_ms,
frozen_requirements,
},
)
@@ -806,12 +793,8 @@ impl CodexProvider {
selected: SelectedGenerate,
) -> Result<ProviderStream, ProviderError> {
let SelectedGenerate {
request:
SelectedRequest {
parameters,
mut lease,
account_selection_wait_ms,
},
mut lease,
account_selection_wait_ms,
frozen_requirements,
} = selected;
let Operation::Generate(generate) = operation else {
@@ -951,7 +934,7 @@ impl CodexProvider {
.then(|| crate::transport::diagnostics::evidence::request_state(&upstream_request));
scope_request_to_account(
&mut upstream_request,
&parameters.installation_id,
lease.installation_id(),
account_scope,
);
if let Some(before) = state_before {
@@ -1106,7 +1089,6 @@ impl CodexProvider {
AttemptTransport::Default | AttemptTransport::Fallback => 0,
};
let events = cold_response_stream(ColdResponse {
parameters,
client: self
.client_for_request(&context)?
.for_account(lease.account())
@@ -33,7 +33,6 @@ pub(super) struct OpenAiResponseObservationState {
stream: bool,
requested_service_tier: Option<String>,
upstream_service_tier: Option<String>,
upstream_response_model: Option<String>,
rate_limit_headers: Vec<(String, String)>,
timings: ProviderResponseTimings,
terminal: Option<OpenAiResponseTerminal>,
@@ -80,7 +79,6 @@ impl OpenAiResponseObservationState {
stream: request.stream(),
requested_service_tier: normalize_service_tier(request.service_tier()),
upstream_service_tier: None,
upstream_response_model: response.response_metadata.effective_model.clone(),
rate_limit_headers: selected_observation_headers(&response.rate_limit_headers),
timings: openai_response_timings(
&response.transport_metrics,
@@ -123,23 +121,9 @@ impl OpenAiResponseObservationState {
if let Some(service_tier) = &self.requested_service_tier {
observation = observation.with_service_tier_if_valid(service_tier.clone());
}
if let Some(model) = &self.upstream_response_model {
observation = observation.with_upstream_response_model_if_valid(model);
}
Some(observation)
}
pub(super) fn observe_upstream_response_model(&mut self, model: Option<&str>) -> bool {
let Some(model) = model else {
return false;
};
if self.upstream_response_model.as_deref() == Some(model) {
return false;
}
self.upstream_response_model = Some(model.to_owned());
true
}
pub(super) fn observe_stream_chunk(&mut self, chunk: &[u8], started_at: Instant) -> bool {
if chunk.is_empty() {
return false;
@@ -263,17 +247,6 @@ impl OpenAiResponseObservationState {
{
metadata.insert("websocketOpeningRequestId".to_owned(), json!(request_id));
}
if let Some(model) = self
.response_metadata
.effective_model
.as_deref()
.filter(|model| !model.is_empty())
{
metadata.insert(
"upstreamReportedModel".to_owned(),
Value::String(model.to_owned()),
);
}
metadata.insert(
"modelsEtag".to_owned(),
self.response_metadata
@@ -1,35 +0,0 @@
//! OpenAI 原生请求的构造参数;只解释本次值,不维护插件身份策略
use gateway_core::{
error::{ProviderError, ProviderErrorKind},
upstream::UpstreamSendState,
};
use serde::Deserialize;
use serde_json::{Map, Value};
#[derive(Deserialize)]
pub(super) struct RequestParameters {
pub(super) installation_id: String,
}
impl RequestParameters {
pub(super) fn baseline(installation_id: &str) -> Map<String, Value> {
Map::from_iter([(
"installation_id".into(),
Value::String(installation_id.to_owned()),
)])
}
pub(super) fn decode(parameters: Option<Map<String, Value>>) -> Result<Self, ProviderError> {
let invalid = || {
ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
)
.with_retry_prohibited()
};
// 参数基线已在选号后提供,显式删除和类型错误不能回退为账号默认身份
serde_json::from_value(Value::Object(parameters.ok_or_else(invalid)?))
.map_err(|_| invalid())
}
}
@@ -14,10 +14,7 @@ use gateway_core::event::{
};
use gateway_core::metering::Usage;
use gateway_core::upstream::UpstreamSendState;
use gateway_protocol::openai::events::{
ResponseModelObservation, TokenUsage, billable_usage_is_complete, extract_usage,
observed_model_name,
};
use gateway_protocol::openai::events::{TokenUsage, billable_usage_is_complete, extract_usage};
use gateway_protocol::openai::sse::{SseEvent, SseEventDecoder, SseFrame, sse_frame_is_done};
use serde_json::Value;
use thiserror::Error;
@@ -59,8 +56,6 @@ pub struct CodexCanonicalDecoder {
response_service_tier: Option<String>,
upstream_response_ms: Option<u64>,
upstream_timing_metrics: ResponseTimingMetrics,
response_model: ResponseModelObservation,
reported_model: Option<String>,
web_search_pricing: Option<WebSearchPricing>,
timing_signals: ResponseEventSignals,
raw_sse_passthrough: bool,
@@ -171,8 +166,6 @@ impl CodexCanonicalDecoder {
response_service_tier: None,
upstream_response_ms: None,
upstream_timing_metrics: ResponseTimingMetrics::default(),
response_model: ResponseModelObservation::default(),
reported_model: None,
web_search_pricing: None,
pricing: None,
timing_signals: ResponseEventSignals::default(),
@@ -288,28 +281,6 @@ impl CodexCanonicalDecoder {
self.upstream_timing_metrics
}
/// 真实 HTTP 响应头提供初始报告;流内请求级报告可覆盖它
#[must_use]
pub fn with_reported_model(mut self, model: Option<&str>) -> Self {
self.reported_model = model.and_then(observed_model_name).map(str::to_owned);
self
}
/// 接收 transport 已解析的内部 metadata 报告,内部帧无需交付客户端
pub(crate) fn observe_reported_model(&mut self, model: &str) {
if let Some(model) = observed_model_name(model) {
self.reported_model = Some(model.to_owned());
}
}
/// 官方服务端报告优先;缺少报告时仅使用正文明确声明,不使用请求兜底值
#[must_use]
pub fn response_model(&self) -> Option<&str> {
self.reported_model
.as_deref()
.or_else(|| self.response_model.model())
}
fn decode(&mut self, events: Vec<SseEvent>) -> CodexCanonicalOutcome {
let mut output = Vec::new();
for event in events {
@@ -419,10 +390,6 @@ impl CodexCanonicalDecoder {
}
}
self.observe_response_service_tier(&value);
self.response_model.observe(event_type, &value);
if let Some(model) = super::response_meta::reported_model_from_event(&value) {
self.reported_model = Some(model.to_owned());
}
let signals = response_event_signals(event_type, &value);
self.merge_timing_signals(signals);
if matches!(event_type, Some("response.failed" | "error")) {
@@ -622,10 +622,7 @@ impl CodexBackendClient {
let mut key = CodexWebSocketPoolKey::new(&self.base_url, account_id, conversation_id)
.with_egress_key(&self.egress_key)
.with_outbound_proxy(self.outbound_proxy.as_ref())
.with_connection_profile(format!(
"{connection_profile}\0{}",
context.installation_id.unwrap_or_default(),
));
.with_connection_profile(connection_profile);
if let Some(connection_id) = request.downstream_websocket_connection_id.as_deref() {
key = key.with_downstream_connection_id(connection_id);
}
@@ -11,8 +11,6 @@ use super::diagnostics::CodexUpstreamDiagnostics;
/// Codex Responses 上游响应元数据
#[derive(Clone, Default, PartialEq, Eq)]
pub struct CodexResponseMetadata {
/// 上游实际选用的模型
pub effective_model: Option<String>,
/// 模型目录版本
pub models_etag: Option<String>,
/// 上游是否声明响应包含 reasoning
@@ -25,7 +23,6 @@ impl fmt::Debug for CodexResponseMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexResponseMetadata")
.field("effective_model", &self.effective_model)
.field("models_etag", &self.models_etag)
.field("reasoning_included", &self.reasoning_included)
.field("client_header_count", &self.client_headers.len())
@@ -119,37 +116,12 @@ fn observe_typed_response_header(metadata: &mut CodexResponseMetadata, name: &st
return;
}
match name.trim().to_ascii_lowercase().as_str() {
"openai-model" | "x-openai-model" => {
metadata.effective_model =
gateway_protocol::openai::events::observed_model_name(value).map(str::to_owned)
}
"x-models-etag" => metadata.models_etag = Some(value.to_string()),
"x-reasoning-included" => metadata.reasoning_included = true,
_ => {}
}
}
/// 与官方 Codex 一致,优先读取 response.headers,再读取 WS metadata 的顶层 headers
pub(super) fn reported_model_from_event(value: &serde_json::Value) -> Option<&str> {
[value.pointer("/response/headers"), value.get("headers")]
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_object)
.find_map(|headers| {
headers.iter().find_map(|(name, value)| {
if !name.eq_ignore_ascii_case("openai-model")
&& !name.eq_ignore_ascii_case("x-openai-model")
{
return None;
}
value
.as_str()
.or_else(|| value.as_array()?.first()?.as_str())
.and_then(gateway_protocol::openai::events::observed_model_name)
})
})
}
fn filter_client_headers(
headers: impl IntoIterator<Item = (String, Bytes)>,
) -> Vec<(String, Bytes)> {
@@ -59,8 +59,6 @@ pub type CodexWebSocketRateLimitUpdates = Arc<Mutex<Vec<ParsedRateLimits>>>;
pub struct CodexWebSocketResponseMetadataUpdate {
/// 当前响应首次声明的会话续接状态
pub turn_state: Option<String>,
/// 当前响应最新的服务端模型报告
pub reported_model: Option<String>,
}
pub type CodexWebSocketResponseMetadataUpdates = Arc<Mutex<CodexWebSocketResponseMetadataUpdate>>;
@@ -72,8 +70,7 @@ pub(super) fn reusable_websocket_metadata(
metadata.set_cookie_headers.clear();
metadata.rate_limit_headers.clear();
metadata.turn_state = None;
// 模型报告属于上一轮请求,池中连接不能把它带到下一轮缺失报告的响应
metadata.response_metadata.effective_model = None;
// 请求级响应头不能随池中连接带到下一轮响应
metadata
.response_metadata
.client_headers
@@ -62,9 +62,6 @@ pub(super) fn reduce_websocket_event(
&mut metadata.response_metadata,
websocket_metadata_headers(&value),
);
if let Some(model) = response_meta::reported_model_from_event(&value) {
metadata.response_metadata.effective_model = Some(model.to_owned());
}
let turn_state_update = websocket_metadata_turn_state(&value).and_then(|turn_state| {
if metadata.turn_state.is_some() {
return None;
@@ -75,7 +75,6 @@ pub(in crate::transport::websocket) fn stream_websocket_response(
let rate_limit_updates_for_task = Arc::clone(&rate_limit_updates);
let response_metadata_updates = Arc::new(Mutex::new(CodexWebSocketResponseMetadataUpdate {
turn_state: metadata.turn_state.clone(),
reported_model: None,
}));
let response_metadata_updates_for_task = Arc::clone(&response_metadata_updates);
let (tx, rx) = mpsc::channel(WEBSOCKET_STREAM_BUFFER);
@@ -306,9 +305,6 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
pending.turn_state = Some(turn_state);
}
}
if let Some(model) = metadata.response_metadata.effective_model.as_ref() {
response_metadata_updates.lock().await.reported_model = Some(model.clone());
}
let terminal = match reduced.action {
ExchangeAction::RateLimits(rate_limits) => {
rate_limit_updates.lock().await.push(rate_limits);
@@ -2443,7 +2443,7 @@ mod errors {
#[tokio::test]
async fn manual_refresh_preserves_banned_evidence_without_promoting_401_to_terminal() {
for (status, kind, message) in [
(400, Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"),
(400, Kind::Invalid, "Codex 账号已被停用,请检查账号状态"),
(
401,
Kind::BadGateway,
@@ -2,337 +2,6 @@
use super::*;
#[derive(Debug)]
struct ParametersMiddleware(Map<String, Value>);
impl MiddlewarePlan for ParametersMiddleware {
fn handle(
&self,
context: MiddlewareContext,
request: MiddlewareRequest,
next: MiddlewareNext,
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
assert_eq!(context.mount(), MiddlewareMount::Attempt);
assert!(request.provider_parameters().unwrap()["installation_id"].is_string());
next.run(request.with_provider_parameters(self.0.clone()))
}
}
#[tokio::test]
async fn request_parameters_drive_native_identity_and_derivation_across_http_and_websocket() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let identities = [
"f77b9b8e-563a-4d84-a66f-f2a0c98d5a8e",
"f6e974b5-708d-4940-b619-1ab2c58c2f2c",
];
let mut captures = Vec::new();
for identity in identities {
for websocket in [false, true] {
let (base_url, capture): (
String,
BoxFuture<'static, (reqwest::header::HeaderMap, Value)>,
) = if websocket {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut headers = None;
let mut socket =
crate::transport::accept_codex_test_websocket_with(socket, |request, _| {
headers = Some(request.headers().clone())
})
.await;
let frame = socket.next().await.unwrap().unwrap();
let body = serde_json::from_str(frame.to_text().unwrap()).unwrap();
socket.send(Message::Text(json!({"type":"response.completed", "response":{
"id":"resp_parameters", "model":"gpt-5.4", "status":"completed", "output":[]
}}).to_string().into())).await.unwrap();
(headers.unwrap(), body)
});
(url, Box::pin(async { task.await.unwrap() }))
} else {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
(
server.uri(),
Box::pin(async move {
let requests = server.received_requests().await.unwrap();
(
requests[0].headers.clone(),
captured_request_body(&requests[0]),
)
}),
)
};
let payload = payload(websocket);
let mut request_context = payload.context().clone();
let mut turn_metadata = metadata();
turn_metadata["installation_id"] = json!("client-installation");
request_context.insert("turn_metadata".into(), json!(turn_metadata.to_string()));
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(
payload.with_context(request_context),
)),
),
context_with_middleware(
"req_parameters",
Arc::new(ParametersMiddleware(Map::from_iter([(
"installation_id".into(),
json!(identity),
)]))),
FastMode::Default,
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
"acct_provider_contract"
);
while let Some(event) = stream.next().await {
event.unwrap();
}
let (headers, body) = capture.await;
assert_eq!(body["client_metadata"]["x-codex-installation-id"], identity);
let metadata: Value =
serde_json::from_str(headers["x-codex-turn-metadata"].to_str().unwrap()).unwrap();
assert_eq!(metadata["installation_id"], identity);
assert_eq!(
headers["session-id"].to_str().unwrap(),
body["client_metadata"]["session_id"]
);
captures.push((
body["client_metadata"]["session_id"].clone(),
body["prompt_cache_key"].clone(),
));
}
}
assert_eq!(captures[0], captures[1]);
assert_eq!(captures[2], captures[3]);
assert_ne!(captures[0].0, captures[2].0);
assert_ne!(captures[0].1, captures[2].1);
}
#[tokio::test]
async fn request_parameters_partition_new_websocket_chains_without_preventing_reuse() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let identities = ["parameters-first", "parameters-second"];
let server = tokio::spawn(async move {
let mut retained = Vec::new();
for (index, identity) in identities.into_iter().enumerate() {
let (socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept())
.await
.unwrap()
.unwrap();
let mut socket =
crate::transport::accept_codex_test_websocket_with(socket, |request, _| {
let metadata: Value = serde_json::from_str(
request.headers()["x-codex-turn-metadata"].to_str().unwrap(),
)
.unwrap();
assert_eq!(metadata["installation_id"], identity);
})
.await;
for turn in 0..=index {
let text = tokio::time::timeout(Duration::from_secs(3), async {
loop {
match socket.next().await.unwrap().unwrap() {
Message::Text(text) => break text,
Message::Ping(payload) => {
socket.send(Message::Pong(payload)).await.unwrap()
}
message => panic!("unexpected websocket frame: {message:?}"),
}
}
})
.await
.unwrap();
let body: Value = serde_json::from_str(&text).unwrap();
assert_eq!(body["client_metadata"]["x-codex-installation-id"], identity);
socket.send(Message::Text(json!({"type":"response.completed", "response":{
"id":format!("resp_parameters_{index}_{turn}"), "model":"gpt-5.4", "status":"completed", "output":[]
}}).to_string().into())).await.unwrap();
}
// 保持旧连接可复用,证明新身份的隔离不是由测试服务端断开触发
retained.push(socket);
}
});
let provider = provider_with_base_url(&store, url);
for identity in [identities[0], identities[1], identities[1]] {
let payload = payload(true);
let mut request_context = payload.context().clone();
request_context.insert(
"turn_metadata".into(),
json!(json!({"installation_id":"client-installation"}).to_string()),
);
let mut stream = Arc::clone(&provider)
.execute(
planned_request(
"openai",
Operation::Generate(
GenerateRequest::from_protocol_payload(
payload.with_context(request_context),
)
.with_provider_session_state(
ProviderSessionState::new(
"openai",
Map::from_iter([
("account_id".into(), json!("acct_provider_contract")),
("conversation_id".into(), json!("parameters-conversation")),
("continuation_scope".into(), json!("persisted")),
]),
)
.unwrap(),
),
),
),
context_with_middleware(
"req_parameters_pool",
Arc::new(ParametersMiddleware(Map::from_iter([(
"installation_id".into(),
json!(identity),
)]))),
FastMode::Default,
),
)
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(5), async {
while let Some(event) = stream.next().await {
event.unwrap();
}
})
.await
.unwrap();
}
server.await.unwrap();
}
#[tokio::test]
async fn request_parameters_apply_to_images_and_search_without_reencoding_opaque_bodies() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.expect(3)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
let body = Bytes::from_static(br#"{ "future":1, "future":2 }"#);
let raw = RawJsonPayload::new("openai", body.clone())
.unwrap()
.with_context(Map::from_iter([(
"turn_metadata".into(),
json!(json!({"installation_id":"client-installation"}).to_string()),
)]));
for operation in [
Operation::GenerateImage(ImageRequest::from_raw_json(
ImageRequestKind::Generation,
raw.clone(),
)),
Operation::GenerateImage(ImageRequest::from_raw_json(
ImageRequestKind::Edit,
raw.clone(),
)),
Operation::Search(StandaloneSearchRequest::from_raw_json(raw)),
] {
let mut stream = Arc::clone(&provider)
.execute(
planned_provider_endpoint_request("openai", operation),
context_with_middleware(
"req_parameters_json",
Arc::new(ParametersMiddleware(Map::from_iter([(
"installation_id".into(),
json!("plugin-installation"),
)]))),
FastMode::Default,
),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
}
for request in server.received_requests().await.unwrap() {
assert_eq!(request.body.as_slice(), body.as_ref());
let metadata: Value =
serde_json::from_str(request.headers["x-codex-turn-metadata"].to_str().unwrap())
.unwrap();
assert_eq!(metadata["installation_id"], "plugin-installation");
}
}
#[tokio::test]
async fn invalid_request_parameters_fail_without_account_identity_fallback_or_send() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let server = MockServer::start().await;
let provider = provider_with_base_url(&store, server.uri());
for parameters in [
json!({}),
json!({"installation_id":null}),
json!({"installation_id":42}),
] {
let error = provider
.clone()
.execute(
planned_request("openai", http_generate_operation()),
context_with_middleware(
"req_invalid_parameters",
Arc::new(ParametersMiddleware(
parameters.as_object().unwrap().clone(),
)),
FastMode::Default,
),
)
.await
.err()
.expect("invalid parameters must fail");
assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(error.retry_is_prohibited());
}
assert!(server.received_requests().await.unwrap().is_empty());
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let mut stream = provider
.execute(
planned_request("openai", http_generate_operation()),
context("req_after_invalid_parameters", CancellationToken::new()),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
}
fn metadata() -> Value {
json!({"session_id":"source-session", "thread_id":"source-thread", "turn_id":"source-turn",
"parent_thread_id":"source-session", "forked_from_thread_id":"source-session",
@@ -618,7 +618,7 @@ async fn attempt_middleware_can_change_reasoning_before_native_encoding() {
}
#[tokio::test]
async fn responses_bill_sent_model_and_observe_unpriced_response_without_rewriting_it() {
async fn responses_bill_sent_model_and_preserve_returned_model() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let server = MockServer::start().await;
@@ -655,7 +655,6 @@ async fn responses_bill_sent_model_and_observe_unpriced_response_without_rewriti
)
.await
.unwrap();
let mut observed = None;
let mut returned = None;
let mut costs = Vec::new();
while let Some(event) = stream.next().await {
@@ -665,9 +664,6 @@ async fn responses_bill_sent_model_and_observe_unpriced_response_without_rewriti
costs.push(cost.total().amount().scaled());
}
}
if let Some(observation) = event.response_observation() {
observed = observation.upstream_response_model().map(str::to_owned);
}
if let Some(wire) = event.wire_event()
&& wire.data().get("type").and_then(Value::as_str) == Some("response.completed")
{
@@ -681,12 +677,11 @@ async fn responses_bill_sent_model_and_observe_unpriced_response_without_rewriti
let requests = server.received_requests().await.expect("upstream requests");
assert_eq!(captured_request_body(&requests[0])["model"], "gpt-5.4");
assert_eq!(costs, vec![3_437_500]);
assert_eq!(observed.as_deref(), Some("gpt-6-sol"));
assert_eq!(returned, observed);
assert_eq!(returned.as_deref(), Some("gpt-6-sol"));
}
#[tokio::test]
async fn websocket_bills_sent_model_and_observes_internal_model_report() {
async fn websocket_bills_sent_model_and_preserves_response_metadata() {
for metadata_type in ["response.metadata", "codex.response.metadata"] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
@@ -729,7 +724,6 @@ async fn websocket_bills_sent_model_and_observes_internal_model_report() {
)
.await
.expect("provider stream");
let mut observed = None;
let mut returned = None;
let mut costs = Vec::new();
while let Some(event) = stream.next().await {
@@ -739,9 +733,6 @@ async fn websocket_bills_sent_model_and_observes_internal_model_report() {
costs.push(cost.total().amount().scaled());
}
}
if let Some(observation) = event.response_observation() {
observed = observation.upstream_response_model().map(str::to_owned);
}
if let Some(wire) = event.wire_event()
&& wire.data().get("type").and_then(Value::as_str) == Some("response.completed")
{
@@ -754,11 +745,6 @@ async fn websocket_bills_sent_model_and_observes_internal_model_report() {
}
server.await.expect("server task");
assert_eq!(costs, vec![3_437_500]);
assert_eq!(
observed.as_deref(),
Some("gpt-server-report"),
"{metadata_type}"
);
assert_eq!(returned.as_deref(), Some("gpt-6-astra"));
}
}
@@ -4116,10 +4102,6 @@ async fn websocket_fast_path_miss_uses_http_and_keeps_background_preconnect() {
);
}
assert_eq!(metadata["upstreamStatus"], 200);
assert_eq!(
metadata["upstreamReportedModel"],
"gpt-reported-by-upstream"
);
assert!(metadata["requestSummary"].is_object());
assert!(metadata["upstreamTraceHeaders"].is_array());
first_decision = metadata
@@ -9,8 +9,8 @@ use gateway_core::account::{
AccountModelAccess, AccountModelAccessMode, FastMode, ProviderAccountId, ProviderAccountStore,
};
use gateway_core::engine::middleware::{
MiddlewareContext, MiddlewareError, MiddlewareNext, MiddlewarePlan, MiddlewareRequest,
MiddlewareResponse,
MiddlewareContext, MiddlewareError, MiddlewareHeader, MiddlewareNext, MiddlewarePlan,
MiddlewareRequest, MiddlewareResponse,
};
use gateway_core::engine::provider::{Provider, ProviderRequest};
use gateway_core::lifecycle::CancellationToken;
@@ -202,28 +202,8 @@ async fn live_bootstrap_preserves_model_through_middleware_and_metadata() {
upstream.verify().await;
}
#[derive(Debug)]
struct ParametersMiddleware;
impl MiddlewarePlan for ParametersMiddleware {
fn handle(
&self,
_context: MiddlewareContext,
request: MiddlewareRequest,
next: MiddlewareNext,
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
assert!(request.provider_parameters().unwrap()["installation_id"].is_string());
next.run(
request.with_provider_parameters(serde_json::Map::from_iter([(
"installation_id".into(),
json!("live-plugin-installation"),
)])),
)
}
}
#[tokio::test]
async fn live_create_uses_request_parameters_without_changing_call_ownership() {
async fn live_create_uses_outbound_rewrite_without_changing_call_ownership() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, ACCOUNT).await;
let server = MockServer::start().await;
@@ -247,11 +227,16 @@ async fn live_create_uses_request_parameters_without_changing_call_ownership() {
let mut stream = provider
.execute(
request,
context_with_middleware(
"req_live_parameters",
Arc::new(ParametersMiddleware),
FastMode::Default,
),
super::contract::outbound_context("req_live_outbound", |request| {
request
.headers
.retain(|header| header.name() != "x-codex-turn-metadata");
request.headers.push(MiddlewareHeader::new(
"x-codex-turn-metadata",
Bytes::from(json!({"installation_id":"live-plugin-installation"}).to_string()),
));
Ok(())
}),
)
.await
.unwrap();
@@ -83,56 +83,6 @@ fn diagnostic_parse_failure_keeps_raw_passthrough_without_persisting_plaintext()
assert!(!snapshot.to_string().contains("private-invalid-json"));
}
#[test]
fn response_model_observation_prefers_reported_headers_without_changing_wire() {
for websocket in [false, true] {
let mut decoder = CodexCanonicalDecoder::new("requested")
.with_reported_model(Some("opening-model"))
.with_raw_sse_passthrough();
for (value, expected) in [
(
json!({"type":"response.created","response":{"id":"resp_model","model":"body-model"}}),
"opening-model",
),
(
json!({"type":"codex.response.metadata","headers":{"X-OpenAI-Model":["metadata-model"]}}),
"metadata-model",
),
(
json!({"type":"response.completed","headers":{"openai-model":"top-level"},"response":{"id":"resp_model","model":"final-body-model","headers":{"OpenAI-Model":"terminal-model"}}}),
"terminal-model",
),
] {
let raw = value.to_string();
let frame = if websocket {
websocket_event_to_sse_frame(&raw).expect("WS event")
} else {
format!("data: {raw}\n\n")
};
let events = decoder
.push(frame.as_bytes())
.expect("decode model observation");
assert_eq!(decoder.response_model(), Some(expected));
assert!(
events
.iter()
.any(|event| event.wire_event().is_some_and(|wire| wire.data() == &value))
);
}
}
}
#[test]
fn response_model_observation_uses_explicit_body_and_never_request_fallback() {
let mut decoder = CodexCanonicalDecoder::new("requested");
decoder
.push(b"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_model\"}}\n\n")
.expect("response model event");
assert_eq!(decoder.response_model(), None);
decoder.push(b"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_model\",\"model\":\"returned\"}}\n\n").expect("response model event");
assert_eq!(decoder.response_model(), Some("returned"));
}
const METADATA_PREFIX_FIXTURE: &str = include_str!("fixtures/metadata_only_prefix.sse");
#[test]
@@ -475,7 +425,6 @@ fn decoder_should_bill_the_sent_model_independently_of_the_response_model() {
_ => None,
});
assert_eq!(cost, expected_cost, "{sent} -> {returned:?}");
assert_eq!(decoder.response_model(), returned);
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::Completed(meta) if meta.model() == Some(returned.unwrap_or(sent))
@@ -431,10 +431,6 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
.await
.expect("response should succeed");
assert_eq!(
response.response_metadata.effective_model.as_deref(),
Some("gpt-5.5-2026-07-01")
);
assert_eq!(
response.response_metadata.models_etag.as_deref(),
Some("models-v2")
@@ -180,7 +180,6 @@ struct CollectedBackendResponse {
rate_limit_headers: Vec<(String, String)>,
websocket_pool_decision: Option<WebSocketPoolDecision>,
response_metadata: CodexResponseMetadata,
reported_model: Option<String>,
transport_metrics: CodexTransportMetrics,
connection_local_continuation: bool,
}
@@ -286,11 +285,9 @@ async fn collect_backend_response(
rate_limit_headers.extend(rate_limits_to_header_pairs(update));
}
}
let mut reported_model = response_metadata.effective_model.clone();
if let Some(update) = response_metadata_updates {
let update = update.lock().await;
turn_state = update.turn_state.clone().or(turn_state);
reported_model = update.reported_model.clone().or(reported_model);
}
let body = String::from_utf8_lossy(&body_bytes).into_owned();
let usage = extract_sse_usage(&body).map_err(CodexClientError::InvalidSse)?;
@@ -303,7 +300,6 @@ async fn collect_backend_response(
rate_limit_headers,
websocket_pool_decision,
response_metadata,
reported_model,
transport_metrics,
connection_local_continuation,
})
@@ -80,7 +80,6 @@ async fn reused_websocket_should_keep_response_metadata_scoped_to_each_exchange(
.expect("response within timeout")
.expect("response should complete on the same socket");
assert_eq!(response.response_metadata, expected, "exchange {round}");
assert_eq!(response.reported_model, Some(format!("model-{round}")));
assert!(response.body.contains(&format!("etag-{round}")));
assert_eq!(response.turn_state, Some(format!("turn-{round}")));
if round > 0 {
@@ -993,10 +993,6 @@ async fn websocket_execute_response_create_request_should_capture_internal_metad
server.await.unwrap();
assert_eq!(response.turn_state.as_deref(), Some("turn-from-metadata"));
assert_eq!(
response.reported_model.as_deref(),
Some("gpt-internal-report")
);
assert!(!response.body.contains("codex.rate_limits"));
assert!(response.body.contains("response.metadata"));
assert!(response.body.contains("event: response.completed"));
@@ -1132,11 +1128,6 @@ async fn reused_websocket_should_not_leak_turn_state_into_the_next_response() {
.websocket_pool_decision
.is_some_and(WebSocketPoolDecision::is_reuse)
);
assert_eq!(
first.reported_model.as_deref(),
Some("first-response-model")
);
assert_eq!(second.reported_model, None);
assert_eq!(second.turn_state, None);
assert!(
second
@@ -296,8 +296,7 @@ impl GrokBuildProvider {
provider_kind,
Some(model),
account_id,
None,
Box::new(move |operation, middleware_headers, _| {
Box::new(move |operation, middleware_headers| {
Box::pin(async move {
if let Some(adapter) = adapter {
return provider.execute_upstream_adapter(
@@ -322,10 +322,6 @@ pub(super) fn cold_compaction_http_sse_stream(
}
}
if let Some(model) = canonical.response_model() {
observation = observation.with_upstream_response_model_if_valid(model);
yield ProviderEvent::observation(observation.clone());
}
if let Some(tier) = canonical.response_service_tier() {
observation = observation.with_service_tier_if_valid(tier.to_owned());
yield ProviderEvent::observation(observation);
@@ -606,12 +602,6 @@ pub(super) fn cold_http_sse_stream(
if observe_output_timings(&mut observation, &events, context.timing_started_at()) {
yield ProviderEvent::observation(observation.clone());
}
if let Some(model) = decoder.response_model()
&& observation.upstream_response_model() != Some(model)
{
observation = observation.with_upstream_response_model_if_valid(model);
yield ProviderEvent::observation(observation.clone());
}
if let Some(tier) = decoder.response_service_tier()
&& observation.service_tier() != Some(tier)
{
@@ -667,12 +657,6 @@ pub(super) fn cold_http_sse_stream(
.iter()
.flat_map(ProviderEvent::canonical_facts)
.any(|event| matches!(event, GatewayEvent::Completed(_)));
if let Some(model) = decoder.response_model()
&& observation.upstream_response_model() != Some(model)
{
observation = observation.with_upstream_response_model_if_valid(model);
yield ProviderEvent::observation(observation.clone());
}
if let Some(tier) = decoder.response_service_tier()
&& observation.service_tier() != Some(tier)
{
@@ -15,9 +15,7 @@ use gateway_core::metering::{
CurrencyCode, Decimal, Money, ProviderReportedCost, Usage,
};
use gateway_core::upstream::UpstreamSendState;
use gateway_protocol::openai::events::{
ResponseModelObservation, TokenUsage, billable_usage_is_complete, extract_usage,
};
use gateway_protocol::openai::events::{TokenUsage, billable_usage_is_complete, extract_usage};
use gateway_protocol::openai::sse::{SseEvent, SseEventDecoder};
use serde_json::Value;
@@ -295,7 +293,6 @@ pub struct GrokCanonicalDecoder {
tool_arguments_seen: BTreeSet<u32>,
usage_emitted: bool,
response_service_tier: Option<String>,
response_model: ResponseModelObservation,
requires_provider_cost: bool,
}
@@ -372,7 +369,6 @@ impl GrokCanonicalDecoder {
tool_arguments_seen: BTreeSet::new(),
usage_emitted: false,
response_service_tier: None,
response_model: ResponseModelObservation::default(),
requires_provider_cost: false,
}
}
@@ -416,12 +412,6 @@ impl GrokCanonicalDecoder {
self.response_service_tier.as_deref()
}
/// 返回原始上游响应声明的模型,缺失时不使用请求模型补齐
#[must_use]
pub fn response_model(&self) -> Option<&str> {
self.response_model.model()
}
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<ProviderEvent>, ProviderError> {
let events = self.decoder.push(chunk).map_err(protocol_error)?;
self.decode(events).map(|batch| batch.projected_events)
@@ -522,9 +512,6 @@ impl GrokCanonicalDecoder {
event.retry,
)
.map_err(|_| protocol_error_marker())?;
if !event_type.is_empty() {
self.response_model.observe(Some(&event_type), &value);
}
// 工具转换可能隐藏注入的调用,计费事实必须从转换前的上游事件读取
if let Some(response) = value.get("response") {
if let Some(tier) = response.get("service_tier").and_then(Value::as_str) {
@@ -50,7 +50,6 @@ fn decoder_should_bill_the_sent_model_independently_of_the_response_model() {
provider_cost_ticks(&facts),
reported_cost.filter(|ticks| *ticks > 0)
);
assert_eq!(decoder.response_model(), returned);
assert!(facts.iter().any(|event| matches!(
event,
GatewayEvent::Completed(meta) if meta.model() == Some(returned.unwrap_or(sent))
@@ -58,18 +57,6 @@ fn decoder_should_bill_the_sent_model_independently_of_the_response_model() {
}
}
#[test]
fn response_model_observation_preserves_raw_model_and_missing_values() {
for model in [None, Some("grok-4.6-build")] {
let mut decoder = GrokCanonicalDecoder::new("grok-4.6");
let body = serde_json::json!({"type":"response.created","response":{"id":"resp_model","model":model}});
decoder
.push(format!("data: {body}\n\n").as_bytes())
.expect("decode created");
assert_eq!(decoder.response_model(), model);
}
}
fn terminal_cost_events(
model: &str,
input_tokens: u64,
+2 -2
View File
@@ -2,9 +2,9 @@
# PostgreSQL 服务端镜像:备份客户端(pg_dump/pg_restore)从这里取,与 compose 同源、
# 版本严格一致。升级数据库版本时只需同步修改这里与 deploy/compose.yaml。
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
FROM node:26.12.0-bookworm-slim@sha256:298992629d69bbd5b2b03ad57094d8e4fcb3a8eeb7c4e25d29de656519568913 AS frontend-builder
FROM node:26.12.1-bookworm-slim@sha256:28147155d47dc31abe0a46e0d12b59b2fb3d0a2dcc5dc65fce76a074180b16fe AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
+2 -2
View File
@@ -387,7 +387,7 @@ V2 远程压缩通过 Responses 接口发送 `compaction_trigger`,网关和上
模板启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`,
再通过代理的 Images 接口生成或编辑图片;也可以在需求中明确要求生成并使用图片。
需要支持该能力的客户端、支持图片输入的对话模型,以及有生图权限和额度的 OpenAI 账号。
需要支持该能力的客户端、支持图片输入的对话模型,以及有生图权限和额度的 Codex 账号。
代理不会增加上游权限,xAI 账号不能承接这些 Images 请求
生图不要求开启 WebSocket。要启用客户端 WebSocket,把当前 Provider 的
@@ -395,7 +395,7 @@ V2 远程压缩通过 Responses 接口发送 `compaction_trigger`,网关和上
客户端到代理、代理到上游是两段独立连接。客户端关闭 WebSocket 后,
服务端仍可能用 WebSocket 访问上游;客户端开关不控制服务端连接池和 HTTP 回退策略。
OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不承接必须依赖 WS 的预热及连接内续接。
Codex OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不承接必须依赖 WS 的预热及连接内续接。
仅使用这类账号时,客户端保持 `supports_websockets = false`,避免先尝试 WS 再回退
### 时区与客户端环境信息
+2 -2
View File
@@ -11,7 +11,7 @@ services:
extends:
file: config.yaml
service: postgres-credentials
image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
restart: unless-stopped
command:
- postgres
@@ -45,7 +45,7 @@ services:
extends:
file: config.yaml
service: redis-credentials
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
image: redis:8-alpine@sha256:0bf6577d7774e592b29d093e3ac9390eb91e13d72c21e506afce736de5e0eeb0
restart: unless-stopped
volumes:
- type: bind
+11 -16
View File
@@ -316,8 +316,8 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
Images 的 `x-codex-image-turn-id` 和 Desktop 自动 Git 上下文的 `clientUserMessageId`。
输入项元数据保持不变。Search 的会话 `id` 按同一安装身份域映射,只替换该值的原始字节,其余正文保持不变。
`prompt_cache_key` 同样按安装身份映射,保留同一身份内的缓存共享关系;未提供时不补造。
受信任插件可通过 attempt 构造参数选择本次安装身份;参数只影响出站构造,不改变实际选号与计费归属,
详见 [SDK 请求构造参数](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt)。
受信任插件可读取原始请求,自行生成身份并在 `outbound` 改写完整请求与关联字段;宿主不重新派生或补回业务字段,
实际选号与计费归属保持不变,详见 [SDK 出站请求准备](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#上游出站准备)。
续接 response ID、历史 item/call ID、业务正文和未知扩展不参与映射,
该处理减少显式追踪标识的跨账号复用,不保证会话内容或业务 metadata 无法关联
@@ -475,7 +475,7 @@ Images 排除已知为 Free 套餐的 OAuth 账号;套餐缺失或未知时不
`/v1/alpha/search` 使用相同的 OpenAI Provider 原生端点边界:模型不映射,正文除会话 `id` 的账号归一及显式请求策略外原样转发,
Provider 旁路读取请求中的 `model`,按[账号模型限制](#账号模型限制)选号。
`x-codex-turn-metadata` 在移除客户端账号身份并按本次构造参数重写 installation ID 后转发;上游账号
`x-codex-turn-metadata` 在移除客户端账号身份并按选中账号重写 installation ID 后转发;上游账号
Authorization、Cookie、account ID、originator 和 User-Agent 均由代理安全重建。
Images 与 Search 保留通过现有过滤规则的普通业务请求头及多值字节;Responses 原连接的 turn state
不随独立端点转发,turn metadata 仍由所属端点按当前账号处理。
@@ -1087,7 +1087,7 @@ OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
导入条目未提供 `modelAccess` 且 `settings` 未指定该字段时,新账号默认 `all`,已有账号保留原值;凭据刷新、重新授权和目录刷新均保留政策。
sub2api 的 `credentials.model_mapping` 不转换为本项目的账号模型限制
编辑 OpenAI API Key 账号时,在 `POST /api/admin/accounts/update` 的账号设置中附带 `connection`:
编辑 Codex API Key 账号时,在 `POST /api/admin/accounts/update` 的账号设置中附带 `connection`:
```json
{
@@ -1106,7 +1106,7 @@ sub2api 的 `credentials.model_mapping` 不转换为本项目的账号模型限
`connection.transport` 支持 `http`、`prefer_websocket`。省略 `connection` 时只更新账号设置;
省略 `connection.apiKey` 保留当前密钥,空字符串无效。
OpenAI OAuth 账号只接受 `connection: { transport }`,不接受 `baseUrl`、`apiKey` 或 OAuth token。
Codex OAuth 账号只接受 `connection: { transport }`,不接受 `baseUrl`、`apiKey` 或 OAuth token。
连接设置不能修改账号 ID、Provider 或认证类型,也不接受通用凭据文档。
凭据与设置在同一事务中保存,任一校验或持久化失败均不落库。
`GET /api/admin/accounts/detail` 对 API Key 账号额外返回 `credentialConfiguration: { base_url, transport }`;
@@ -1160,7 +1160,7 @@ OAuth start 使用:
失败会释放流程占用,但上游 authorization code 通常只能交换一次;已完成 token exchange 而未提交账号时应重新创建 OAuth flow
- `GET /api/admin/accounts/quota` 只读取最后一次落库快照;`POST /api/admin/accounts/quota/refresh` 才访问上游。access token
已过期时,额度刷新要求先走 credential 刷新或重新授权,不会拿过期 token 探测额度
- OpenAI 已耗尽账号首次可复核,在 `resetAt` 尚未到达或未知时按 30 分钟间隔兜底,也可发现
- Codex 已耗尽账号首次可复核,在 `resetAt` 尚未到达或未知时按 30 分钟间隔兜底,也可发现
官方提前重置。到达最早未恢复窗口的 `resetAt` 后,先等待 10 秒传播余量,再在下一轮后台检查
复核;reset 后前 5 分钟最小查询间隔为 30 秒,之后仍耗尽则改为 5 分钟。正常账号有非零用量或触顶窗口时,仍在
`resetAt + 2 分钟` 后复核,未更新则按 30 分钟间隔重试。后台每 30 秒检查条件,实际恢复时间还受
@@ -1225,7 +1225,7 @@ OAuth start 使用:
等价 USD 费用不是订阅价格或额度承诺,30 天折算不是自然月额度,记录覆盖率也不代表预测准确率
### OpenAI 账号辅助请求
### Codex 账号辅助请求
OAuth 账号的额度、个人资料、订阅与重置卡请求先使用 `openai.api.base_url`。自定义上游明确返回 HTTP 404 时,
仅回退一次到官方账号接口,沿用当前账号凭据与出站代理;其他状态码、解析错误或传输失败不触发回退。
@@ -1235,7 +1235,7 @@ OAuth 账号的额度、个人资料、订阅与重置卡请求先使用 `openai
### 账号个人信息
`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,提供 OpenAI/Codex OAuth 账号的个人信息。后端并发读取资料统计与订阅,
`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,提供 Codex OAuth 账号的个人信息。后端并发读取资料统计与订阅,
一次返回;每次请求均重新查询,不刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。
查询沿用上述 OpenAI 404 路由回退
@@ -1558,7 +1558,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
定时账号预热默认关闭。`accountWarmupScheduleTime` 使用部署时区中的 `HH:MM`,
多个时段以逗号分隔,默认 `08:00`;`accountWarmupModel` 默认 `null`,开启前必须显式选择模型。
任务面向可用的 OpenAI OAuth 账号,跳过周额度耗尽及五小时窗口距离重置仍超过 30 分钟的账号。
任务面向可用的 Codex OAuth 账号,跳过周额度耗尽及五小时窗口距离重置仍超过 30 分钟的账号。
不存在的本地时刻跳过,重复时刻只执行较早一次;执行进度跨重启保留,时钟回拨不补跑已领取时刻之前的时段。
只有收到响应成功终态才记为预热成功;预热不计入客户端业务用量
@@ -1653,7 +1653,7 @@ Guardian 以 `subagent_kind` 或 `client_metadata.x-openai-subagent` 值 `guardi
### 账号亲和与换号
`openaiAccountAffinity` 控制 OpenAI 账号亲和,默认 `strict`(严格):
`openaiAccountAffinity` 控制 Codex 账号亲和,默认 `strict`(严格):
- `relaxed`(宽松):会话内请求直接按 `rotationStrategy` 选号,不优先主账号
- `preferred`(优先):会话内所有可关联请求优先主账号,并发已满、额度耗尽、停用、限流冷却或不支持当前模型时临时分流,保留会话绑定,后续请求仍优先主账号
@@ -2099,12 +2099,7 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
详情不出现在 Key 用量接口和普通错误信封中;[诊断导出](#诊断与恢复关联)在服务端重新脱敏后包含已存详情。
记录仍受异步观测写入与保留周期约束
管理端请求列表与详情分别保留 `requestedModel`(客户端请求)、`upstreamModel`(网关发送)与
`upstreamResponseModel`(上游返回)。返回模型缺失时为 `null`,不使用请求或映射模型补齐。
OpenAI 优先采用服务端 `openai-model` / `x-openai-model` 报告(流内报告可覆盖初始响应头),
没有报告时采用正文明确声明的 `response.model`;xAI 采用原始正文声明。正文模型以终态优先,
缺少终态声明时保留首次声明。这些值仅表示上游报告,不作为模型真实性证明,也不参与路由、
聚合或本地计价模型选择。没有保存上游模型报告的记录保持未知
管理端请求列表与详情保留 `requestedModel`(客户端请求)与 `upstreamModel`(网关发送)
请求明细的 `clientApiKeyName`、`accountName` 按字面量包含匹配请求保存的历史名称快照,区分大小写,
不解析为 ID,也不依赖当前配置是否存在。`model` 按字面量包含匹配请求模型或上游模型,`%`、`_` 和 `\` 按普通字符处理
+2 -4
View File
@@ -172,8 +172,6 @@ flowchart LR
握手、socket 与断连由 API 持有,会话处理通过原 RPC 资源流保活;HTTP 返回链替换响应时释放未提交的会话。
原生与插件接管的下游 WebSocket 共用消息组合和取消入口,在默认解析前和实际写入前处理完整消息;读写并发进行,插件调用期间不占用 transport 写锁。
`request` 包裹逻辑模型请求,`attempt` 包裹每次已选号的执行;请求顺序进入、响应逆序返回。
attempt 可携带 Provider 的本次构造参数,Core / Runtime 只传递完整替换后的对象,解释和缺失校验由 Provider 负责;
参数不改变真实租约、凭据版本和计费归属,也不承载宿主对插件策略的推断、补值或持久化。
`outbound` 在原生默认值、隐私处理、序列化及内容压缩之后组合完整请求,续体只准备值,Provider 在整条返回链结束后发送。
无绑定或未命中当前请求范围时保留原生请求构造,不进入出站准备链。
它复用本次 attempt 的冻结计划、取消、期限和副作用水位;Provider 不再次应用业务默认值或重写正文,插件负责报文与内容编码的一致性。
@@ -440,7 +438,7 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Cor
Responses 按模型目录编译候选;全局模型映射是精确映射,未命中时模型名原样交给候选 Provider。
Images 与 standalone Search 是 OpenAI Provider 自有端点:两者都不参与文本模型映射,只在 Client Key
的账号范围确实包含 OpenAI 账号时生成单一 OpenAI 候选。Images 的图片模型由 Provider 从正文旁路
的账号范围确实包含 Codex 账号时生成单一 OpenAI 候选。Images 的图片模型由 Provider 从正文旁路
提取,用于账号模型政策检查,并在中间件改写后复验当前租约账号;不要求图片模型进入文本目录。
Search 的请求身份与正文透传规则见 [OpenAI 透传](api.md#openai-透传)
@@ -467,7 +465,7 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
`transport/correlation` 在出站副本上按本次安装身份统一映射关联标识并剥离客户端 trace;
安装身份默认取自账号,可由 attempt 的构造参数改写,缺失或非法结果不回退默认值。
原生安装身份取自账号;插件的身份生成、持久化及关联字段改写通过既有中间件链路自行完成。
内部选号、亲和、连接池与本地诊断继续读取原始身份;具体字段边界见 [API 协议](api.md#请求头与身份)
canonical facts 从同一数据旁路提取,用于路由、恢复判断、观测和计费。
WebSocket transport 按完整文本消息交付,wire 同时保留原文与可选解析视图;同协议出口使用原文,
+3 -3
View File
@@ -14,7 +14,7 @@
## 环境与依赖
需要 Node.js 26.12.0、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
需要 Node.js 26.12.1、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
各前端的 `packageManager` 固定 pnpm 版本,宿主 CI 和 Docker 从 `frontend/package.json` 读取
@@ -114,7 +114,7 @@ pnpm --dir modules/plugins/examples/workbench/frontend build:source
```
`build:source` 用各项目的 `tsconfig.source.json` 检查源码类型,管理端构建到 `frontend/node_modules/.vite/source-dist`,示例构建到其前端项目的 `.vite/source-dist`,不改写正式依赖、锁文件或 `dist`。
源码联调 CI 显式检出子模块、核对 UI 提交锁定并验证这两个命令
源码联调 CI 显式检出子模块,分别按锁文件安装依赖,验证这两个命令并确认联调未改写正式依赖和锁文件
### 本地 SDK 验证
@@ -145,7 +145,7 @@ pnpm --dir modules/plugins/examples/workbench/frontend build:source
1. 在要修改的子模块内先创建分支,例如 `git -C modules/ui switch -c feat/select`。初次初始化的子模块通常处于 detached HEAD
2. 在子模块运行自身检查,并在宿主根目录运行所需联调检查
3. 分别提交、推送并合并组件库或插件 PR。宿主只记录子模块提交号,不能替代子仓库的提交与推送
4. UI 更新后,将管理端和插件页面的 UI 依赖同步到同一固定标签或提交,重新生成各自锁文件,再更新宿主的子模块指针;CI 会核对三处是否一致
4. 管理端与插件页面按需升级 UI 依赖,各自固定标签或提交并维护锁文件,无需与 `modules/ui` 同步版本
5. 提交宿主改动及 `modules/ui`、`modules/plugins` 指针。指针必须指向远程可取得的提交,不能只存在于开发机
更新子模块指针不会自动升级消费方依赖。正式依赖不能写成开发机路径或浮动分支;SDK 仍由插件仓库固定到包含所需合同的宿主提交
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.6.0",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.7.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.59.0",
+15 -6
View File
@@ -195,8 +195,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.44(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.6.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#v0.7.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -443,9 +443,9 @@ packages:
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282':
resolution: {gitHosted: true, integrity: sha512-ZXS77U0WYM25AEmqleT3rQmenv3GcIvmOJsxxvzJWZ7KQvwMXvSwwIqYrQw/y0Ch37214IVeqYTf68yDjx4mvA==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282}
version: 0.6.0
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225':
resolution: {gitHosted: true, integrity: sha512-JJesyJ1Y/QcdXhBtmKX6k0GyUgOSxiV8sJrUymvLyKjC8NhHLmtpXWt/evZcjYBDyJEIRll85bmSmtMF9/Wpqw==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225}
version: 0.7.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -972,6 +972,10 @@ packages:
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0}
'@rc-component/async-validator@6.0.0':
resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==}
engines: {node: '>=14.x'}
'@rolldown/binding-android-arm-eabi@1.2.14':
resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3422,11 +3426,12 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
'@lucide/vue': 1.59.0(vue@3.5.44(typescript@6.0.3))
'@rc-component/async-validator': 6.0.0
'@vueuse/core': 15.0.0(vue@3.5.44(typescript@6.0.3))
'@vueuse/integrations': 15.0.0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(sortablejs@1.15.7)(vue@3.5.44(typescript@6.0.3))
dayjs: 1.11.23
@@ -3858,6 +3863,10 @@ snapshots:
'@pkgr/core@0.3.6': {}
'@rc-component/async-validator@6.0.0':
dependencies:
'@babel/runtime': 7.29.10
'@rolldown/binding-android-arm-eabi@1.2.14':
optional: true
-2
View File
@@ -98,7 +98,6 @@ export interface UsageListRecord {
model: string | null
requestedModel: string | null
upstreamModel: string | null
upstreamResponseModel: string | null
serviceTier: string | null
clientTransport: string
upstreamTransport: string | null
@@ -131,7 +130,6 @@ export interface UsageRecord {
model: string | null
requestedModel: string | null
upstreamModel: string | null
upstreamResponseModel: string | null
serviceTier: string | null
statusCode: number | null
clientTransport: string
+7 -3
View File
@@ -2,7 +2,7 @@
import type { IconButtonProps } from '@codex-proxy/ui'
import type { Locale } from '@/i18n/locale'
import { ZIconButton, ZMenuItem, ZPopover } from '@codex-proxy/ui'
import { Check, Languages } from '@lucide/vue'
import { Check, Languages, LoaderCircle } from '@lucide/vue'
import chineseFlag from 'circle-flags/flags/cn.svg?url&no-inline'
import englishFlag from 'circle-flags/flags/us.svg?url&no-inline'
import { computed, shallowRef, useTemplateRef } from 'vue'
@@ -61,7 +61,8 @@ async function selectLocale(value: Locale, close: () => void) {
v-for="option in localeOptions"
:key="option.value"
:aria-pressed="option.value === locale"
:loading="pendingLocale === option.value"
:disabled="pendingLocale === option.value"
:aria-busy="pendingLocale === option.value || undefined"
class="aria-pressed:bg-cp-control-item-bg-active aria-pressed:text-cp-primary-text aria-pressed:hover:bg-cp-control-item-bg-active"
@click="selectLocale(option.value, close)"
>
@@ -70,7 +71,10 @@ async function selectLocale(value: Locale, close: () => void) {
</template>
<span class="flex items-center justify-between gap-3">
<span :lang="option.value">{{ option.label }}</span>
<Check v-if="option.value === locale" class="size-4 shrink-0" aria-hidden="true" />
<span class="inline-grid size-4 shrink-0 place-items-center" aria-hidden="true">
<LoaderCircle v-if="pendingLocale === option.value" class="size-4 animate-spin motion-reduce:animate-none" />
<Check v-else-if="option.value === locale" class="size-4" />
</span>
</span>
</ZMenuItem>
</div>
@@ -1,3 +1,4 @@
import { escape } from 'es-toolkit'
import { isRecord } from '@/utils/data'
interface ChartTooltipTheme {
@@ -45,6 +46,11 @@ export function chartTooltipStyle(
}
}
export function chartTooltipContent(label: string, lines: string[]) {
const title = label ? `<span style="font-size:13px;line-height:20px">${escape(label)}</span>` : ''
return [title, ...lines].filter(Boolean).join('<br/>')
}
export function tooltipRows(params: unknown): Record<string, unknown>[] {
const values = Array.isArray(params) ? params : [params]
return values.filter(isRecord)
@@ -24,19 +24,17 @@ const { t } = useI18n({ useScope: 'global' })
{{ modelDisplay.primary }}
</code>
<div
v-for="route in modelDisplay.routes"
:key="route.kind"
v-if="modelDisplay.secondary"
class="flex min-w-0 max-w-full items-center gap-1.25 text-cp-text-secondary"
:title="route.description"
:title="t('usage.status.mappedModel', { secondary: modelDisplay.secondary })"
>
<CornerDownRight
class="size-3.25 shrink-0"
:class="route.kind === 'returned' ? 'text-cp-orange-text' : 'text-cp-blue-text'"
class="size-3.25 shrink-0 text-cp-blue-text"
stroke-width="2.4"
aria-hidden="true"
/>
<code class="block truncate font-mono text-cp-xs leading-none font-bold">
{{ route.model }}
{{ modelDisplay.secondary }}
</code>
</div>
</div>
@@ -6,8 +6,8 @@ import { t } from '@/i18n'
import { formatProviderLabel } from '@/utils/providers'
export const usageDataColumns = computed(() => ([
{ key: 'model', label: t('usage.fields.model'), kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: t('usage.fields.reasoningEffort'), kind: 'status', size: 'md', align: 'left' },
{ key: 'model', label: t('usage.fields.model'), kind: 'custom', size: 'lg' },
{ key: 'reasoningEffort', label: t('usage.fields.reasoningEffort'), kind: 'status', size: 'md' },
{ key: 'route', label: t('usage.fields.endpoint'), kind: 'mono' },
{ key: 'upstreamTransport', label: t('usage.fields.upstream'), kind: 'status', size: 'md' },
{ key: 'clientTransport', label: t('usage.fields.access'), kind: 'status', size: 'md' },
@@ -10,7 +10,7 @@ export type UsagePerformanceRecord = Pick<UsageCommonRecord, 'latencyDetails' |
tokenDetails: Pick<UsageListRecord['tokenDetails'], 'outputTokens'> | null
}
export type UsageModelRecord = Pick<UsageCommonRecord, 'model'> & Partial<Pick<UsageCommonRecord, 'requestedModel' | 'upstreamModel' | 'upstreamResponseModel'>>
export type UsageModelRecord = Pick<UsageCommonRecord, 'model'> & Partial<Pick<UsageCommonRecord, 'requestedModel' | 'upstreamModel'>>
export type UsageReasoningRecord = Pick<UsageCommonRecord, 'reasoningEffort'> & Partial<Pick<UsageCommonRecord, 'reasoningPreset' | 'subagentKind'>>
interface UsageTimingItem {
@@ -93,27 +93,7 @@ export function usageModelDisplay(record: UsageModelRecord) {
? storedModel
: ''
const responseModel = record.upstreamResponseModel || ''
const returned = responseModel && responseModel !== primary ? responseModel : ''
const routes = []
if (secondary && secondary !== returned) {
routes.push({
model: secondary,
kind: 'mapped' as const,
description: t('usage.status.mappedModel', { secondary }),
})
}
if (returned) {
routes.push({
model: returned,
kind: 'returned' as const,
description: returned === secondary
? t('usage.status.returnedModel', { returned })
: t('usage.status.returnedModelHelp', { returned }),
})
}
return { primary, secondary, routes }
return { primary, secondary }
}
export function usagePerformanceDetails(record: UsagePerformanceRecord) {
+1 -1
View File
@@ -44,7 +44,7 @@
"usageRate": "Usage rate",
"moreGroups": "{count} more quota groups, expand the account to view them",
"windows": {
"fiveHour": "5 h limit",
"fiveHour": "5h limit",
"daily": "Daily limit",
"weekly": "Weekly limit",
"monthly": "Monthly limit",
@@ -30,6 +30,7 @@
},
"activeAccounts": {
"todayTokens": "Tokens",
"requestCount": "Requests",
"title": "Active account usage",
"sortByRecent": "Sorted by last used",
"empty": "No recent account requests",
+5 -6
View File
@@ -58,10 +58,9 @@
"checkBasicSettings": "Please check basic settings",
"noParameters": "No plugin parameters required",
"sensitiveConfiguration": "Sensitive configuration",
"secretsHelpTitle": "About secret configuration",
"secretsHelp": "Secrets are stored separately and never displayed, Keep secrets preserves existing values and Replace all secrets overwrites them with all values entered here",
"secretsHelp": "Saved tokens, keys and other private values are never shown",
"settingsSaved": "Settings saved: {count}",
"clearSecretsHelp": "Clear all secrets when saving",
"emptySecretPlaceholder": "Leave blank to clear this value",
"about": "About {name}",
"enterNewValue": "Enter new value",
"pluginParameters": "Parameters",
@@ -70,9 +69,9 @@
"applyAndSwitch": "Apply and switch",
"saveAndEnable": "Save and enable",
"saveSettings": "Save",
"keepExistingValues": "Keep secrets",
"replaceAllValues": "Replace all secrets",
"clearAllValues": "Clear all secrets",
"keepExistingValues": "Keep existing",
"replaceAllValues": "Replace all",
"clearAllValues": "Clear all",
"checkPluginParameters": "Please check plugin parameters",
"setRequestScopeOrCheckApplyTo": "Please set the request scope, or check \"Apply to all requests\"",
"invalidIdentityMappings": "Check the client authentication identity mappings",
+8 -2
View File
@@ -150,7 +150,7 @@
},
"scheduling": {
"roundRobin": "Round robin",
"autoReviewSlotsHelp": "Applies to OpenAI accounts only: with a regular concurrency limit of 10 and 3 auto review slots, up to 10 regular requests and 3 auto review requests can run independently",
"autoReviewSlotsHelp": "Applies to Codex accounts only: with a regular concurrency limit of 10 and 3 auto review slots, up to 10 regular requests and 3 auto review requests can run independently",
"autoReviewQueueHelp": "With dedicated slots, auto review uses a separate queue that may exceed account queue capacity, while total queue capacity, queue timeout, and API key limits still apply",
"weights": {
"load": "Load weight",
@@ -282,7 +282,7 @@
"queueTimeoutHelp": "Shared by both queues, 1–120 seconds",
"autoReview": {
"label": "Reserved auto review slots",
"description": "Extra slots per OpenAI account, 0 shares regular concurrency",
"description": "Extra slots per Codex account, 0 shares regular concurrency",
"helpTitle": "About auto review concurrency"
},
"queueTimeoutHelpTitle": "About queue timeout",
@@ -640,6 +640,12 @@
"description": "Manage scheduling, upstreams, pricing, security, history retention, and backups",
"sectionsLabel": "Settings sections",
"undoAllBasicSettingsChanges": "Undo all basic settings changes",
"unsavedChanges": {
"title": "Unsaved changes",
"description": "Your unsaved settings changes will be lost if you leave",
"leave": "Discard and leave",
"stay": "Keep editing"
},
"saveChanges": "Save",
"settingsLoadingFailed": "Settings loading failed",
"reload": "Reload",
@@ -70,7 +70,8 @@
"plugins": {
"title": "Plugin compatibility notice",
"description": "Keep enabled configurations and try starting each plugin after restarting",
"incompatible": "Incompatible plugins"
"incompatible": "Incompatible plugins",
"requestErrorHelp": "If requests fail even after plugins start successfully, try disabling the relevant plugins and retrying"
},
"versionAfterRestart": "Version after restart",
"confirmation": {
+6 -6
View File
@@ -18,7 +18,8 @@
"requestVolume": "Requests",
"noServiceResult": "No service result",
"title": "Health",
"description": "Request outcomes · {granularityText}",
"description": "Request outcomes",
"descriptionWithGranularity": "Request outcomes · {granularityText}",
"completionRate": "Completion",
"abnormalEnd": "Abnormal",
"loading": "Loading requests",
@@ -81,6 +82,7 @@
"reviewSubagentRequest": "Review subagent request"
},
"filters": {
"modelPlaceholder": "Filter by model",
"error": "Error",
"requestDetailType": "Request detail type",
"label": "Usage record filters and actions",
@@ -126,8 +128,6 @@
"future": "Future",
"status": {
"mappedModel": "Model sent upstream after gateway mapping: {secondary}",
"returnedModel": "Upstream response model: {returned} (matches the mapped request model)",
"returnedModelHelp": "Model returned by upstream: {returned}",
"firstOutputWaiting": "First output",
"reasoningToText": "Reasoning → Text",
"firstOneIsOutputToText": "First output → Text",
@@ -167,6 +167,7 @@
"requestsPerMinute": "RPM"
},
"header": {
"title": "Analytics",
"currentKey": "Current key",
"keyPrefix": "Key prefix",
"keyConfiguration": "Key configuration",
@@ -200,8 +201,8 @@
"clickRefreshAtTopToTryAgain": ", please click refresh at the top to try again",
"description": "View usage, performance trends, and request errors",
"dateRangeLabel": "Statistics date range, limited to the last 31 days",
"requestDetails": "Request details",
"recordsDescription": "Successful and failed request details",
"requestDetails": "Request log",
"recordsDescription": "Successful and failed request records",
"failedToLoad": "Failed to load: {error}",
"noRequestsMatchTheseFilters": "No requests match these filters",
"viewErrorDetails": "View error details",
@@ -309,7 +310,6 @@
"message": "Message",
"upstreamTimeConsumption": "Upstream time consumption",
"storageModel": "Storage model",
"upstreamReturnModel": "Upstream return model",
"clientIp": "Client IP",
"eventType": "Event type",
"trySerialNumber": "Try serial number",
@@ -44,7 +44,7 @@
"usageRate": "使用率",
"moreGroups": "另有 {count} 个额度组,可展开账号查看",
"windows": {
"fiveHour": "5 h 额度",
"fiveHour": "5小时额度",
"daily": "日额度",
"weekly": "周额度",
"monthly": "月额度",
@@ -132,10 +132,10 @@
"urlLength": "上游 API 地址不能超过 2048 个字符",
"urlFormat": "请输入不含认证、查询参数或片段的 HTTP 或 HTTPS 地址",
"urlRequired": "请输入完整的上游 API 地址",
"apiKeyFormat": "API Key 不能包含空格或控制字符",
"apiKeyFormat": "API密钥不能包含空格或控制字符",
"transportRequired": "请选择传输方式"
},
"apiKeyRequired": "请输入 API Key"
"apiKeyRequired": "请输入 API密钥"
},
"batchEdit": {
"title": "批量编辑账号",
@@ -167,7 +167,7 @@
"loadError": "上游设置读取失败",
"notesHelp": "最多 500 字,留空可清除备注",
"oauthUnavailable": "该账号没有 OAuth 上游设置",
"apiKeyUnavailable": "该账号没有 API Key 上游设置",
"apiKeyUnavailable": "该账号没有 API密钥上游设置",
"saved": "账号已更新"
},
"filters": {
@@ -347,8 +347,8 @@
"accountAdded": "账号已添加",
"providerRequired": "请选择账号平台",
"accountPlatformIsNotSupported": "账号平台不受支持",
"apiKeyUnsupported": "当前平台不支持 API Key 账号",
"apiKeyAccountAdded": "API Key 账号已添加",
"apiKeyUnsupported": "当前平台不支持 API密钥账号",
"apiKeyAccountAdded": "API密钥账号已添加",
"importTasksCreated": "已创建 {submitted} 个导入任务",
"importTaskCreated": "导入任务已创建",
"partialSubmission": "部分导入任务已创建,保持输入不变重试可继续提交",
@@ -111,7 +111,7 @@
},
"dashboard": {
"justUpdated": "刚刚更新",
"demoKey": "演示 Key",
"demoKey": "演示 API密钥",
"requestHealthTimeline": "请求健康时间线",
"validRequestAvailability": "有效请求可用性",
"todaySToken": "今日 Token",
+4 -4
View File
@@ -2,7 +2,7 @@
"auth": {
"signOut": "退出登录",
"adminSignIn": "管理员登录",
"apiKeySignIn": "API Key 登录",
"apiKeySignIn": "API密钥登录",
"signingIn": "正在登录",
"signIn": "登录",
"method": "选择登录方式",
@@ -11,11 +11,11 @@
"username": "管理员账号",
"usernamePlaceholder": "输入会话账号",
"passwordPlaceholder": "输入会话密钥",
"apiKeyPlaceholder": "输入 API Key",
"apiKeyPlaceholder": "输入 API密钥",
"pageLabel": "Codex Proxy RS 登录",
"password": "密码",
"apiKey": "API 密钥",
"apiKey": "API密钥",
"adminRealm": "管理员",
"keyRealm": "API 密钥"
"keyRealm": "API密钥"
}
}
@@ -28,7 +28,7 @@
"malformedJson": "请求体不是合法 JSON",
"invalidTimeRange": "时间范围不合法",
"invalidCredentials": "登录凭据错误",
"invalidAdminApiKey": "管理 API 密钥无效",
"invalidAdminApiKey": "管理 API密钥无效",
"tooManyLoginAttempts": "登录尝试过多,请稍后重试",
"upstreamResultUnknown": "上游执行结果未知,请刷新状态后再决定是否重试"
},
@@ -112,7 +112,7 @@
"name": "名称",
"createdAt": "创建时间",
"baseUrl": "基础 URL",
"apiKey": "API 密钥",
"apiKey": "API密钥",
"build": "构建"
},
"language": "界面语言",

Some files were not shown because too many files have changed in this diff Show More