Compare commits

...
12 Commits
Author SHA1 Message Date
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
130 changed files with 427 additions and 1410 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()),
@@ -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);
@@ -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
@@ -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
+4 -9
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 仍由所属端点按当前账号处理。
@@ -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` 按字面量包含匹配请求模型或上游模型,`%`、`_` 和 `\` 按普通字符处理
+1 -3
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 不再次应用业务默认值或重写正文,插件负责报文与内容编码的一致性。
@@ -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",
@@ -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",
+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": "月额度",
@@ -29,7 +29,8 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"todayTokens": "Token",
"requestCount": "次数",
"title": "活跃账号用量",
"sortByRecent": "最近使用排序",
"empty": "暂无账号请求记录",
@@ -640,6 +640,12 @@
"description": "配置网关调度、上游与定价,管理访问安全、历史保留与备份",
"sectionsLabel": "设置分区",
"undoAllBasicSettingsChanges": "撤销全部基础设置更改",
"unsavedChanges": {
"title": "有未保存的更改",
"description": "离开后,未保存的设置更改将丢失",
"leave": "放弃并离开",
"stay": "继续编辑"
},
"saveChanges": "保存基础设置",
"settingsLoadingFailed": "设置加载失败",
"reload": "重新加载",
+6 -6
View File
@@ -18,7 +18,8 @@
"requestVolume": "请求量",
"noServiceResult": "无服务结果",
"title": "请求健康",
"description": "请求状态分布 · {granularityText}",
"description": "请求状态分布",
"descriptionWithGranularity": "请求状态分布 · {granularityText}",
"completionRate": "完成率",
"abnormalEnd": "非正常结束",
"loading": "正在加载请求数据",
@@ -81,6 +82,7 @@
"reviewSubagentRequest": "Review 子代理请求"
},
"filters": {
"modelPlaceholder": "按模型筛选",
"error": "错误",
"requestDetailType": "请求明细类型",
"label": "使用记录筛选与操作",
@@ -126,8 +128,6 @@
"future": "未来",
"status": {
"mappedModel": "网关映射后发送给上游的模型:{secondary}",
"returnedModel": "上游返回模型:{returned}(与网关映射后发送的模型一致)",
"returnedModelHelp": "上游返回模型:{returned}",
"firstOutputWaiting": "首个输出等待",
"reasoningToText": "推理到正文",
"firstOneIsOutputToText": "首个输出到正文",
@@ -167,6 +167,7 @@
"requestsPerMinute": "RPM"
},
"header": {
"title": "使用统计",
"currentKey": "当前密钥",
"keyPrefix": "密钥前缀",
"keyConfiguration": "密钥配置",
@@ -200,8 +201,8 @@
"clickRefreshAtTopToTryAgain": ",请点击顶部刷新重试",
"description": "查看请求用量、性能趋势与调用错误记录",
"dateRangeLabel": "统计日期范围,仅可选择最近 31 天",
"requestDetails": "请求明细",
"recordsDescription": "成功请求与失败请求明细",
"requestDetails": "请求记录",
"recordsDescription": "成功请求与失败请求记录",
"failedToLoad": "加载失败:{error}",
"noRequestsMatchTheseFilters": "所选条件下暂无请求记录",
"viewErrorDetails": "查看错误详情",
@@ -309,7 +310,6 @@
"message": "消息",
"upstreamTimeConsumption": "上游耗时",
"storageModel": "存储模型",
"upstreamReturnModel": "上游返回模型",
"clientIp": "客户端 IP",
"eventType": "事件类型",
"trySerialNumber": "尝试序号",
@@ -44,7 +44,7 @@
"usageRate": "使用率",
"moreGroups": "另有 {count} 個額度組,可展開賬號查看",
"windows": {
"fiveHour": "5 h 額度",
"fiveHour": "5小時額度",
"daily": "日額度",
"weekly": "周額度",
"monthly": "月額度",
@@ -29,7 +29,8 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"todayTokens": "Token",
"requestCount": "次數",
"title": "活躍賬號用量",
"sortByRecent": "最近使用排序",
"empty": "暫無賬號請求記錄",
@@ -640,6 +640,12 @@
"description": "配置網關調度、上游與定價,管理訪問安全、歷史保留與備份",
"sectionsLabel": "設置分區",
"undoAllBasicSettingsChanges": "撤銷全部基礎設置更改",
"unsavedChanges": {
"title": "有未儲存的更改",
"description": "離開後,未儲存的設定更改將遺失",
"leave": "放棄並離開",
"stay": "繼續編輯"
},
"saveChanges": "保存基礎設置",
"settingsLoadingFailed": "設置加載失敗",
"reload": "重新加載",
+6 -6
View File
@@ -18,7 +18,8 @@
"requestVolume": "請求量",
"noServiceResult": "無服務結果",
"title": "請求健康",
"description": "請求狀態分佈 · {granularityText}",
"description": "請求狀態分佈",
"descriptionWithGranularity": "請求狀態分佈 · {granularityText}",
"completionRate": "完成率",
"abnormalEnd": "非正常結束",
"loading": "正在加載請求數據",
@@ -81,6 +82,7 @@
"reviewSubagentRequest": "Review 子代理請求"
},
"filters": {
"modelPlaceholder": "按模型篩選",
"error": "錯誤",
"requestDetailType": "請求明細類型",
"label": "使用記錄篩選與操作",
@@ -126,8 +128,6 @@
"future": "未來",
"status": {
"mappedModel": "網關映射後發送給上游的模型:{secondary}",
"returnedModel": "上游返回模型:{returned}(與網關映射後發送的模型一致)",
"returnedModelHelp": "上游返回模型:{returned}",
"firstOutputWaiting": "首個輸出等待",
"reasoningToText": "推理到正文",
"firstOneIsOutputToText": "首個輸出到正文",
@@ -167,6 +167,7 @@
"requestsPerMinute": "RPM"
},
"header": {
"title": "使用統計",
"currentKey": "當前密鑰",
"keyPrefix": "密鑰前綴",
"keyConfiguration": "密鑰配置",
@@ -200,8 +201,8 @@
"clickRefreshAtTopToTryAgain": ",請點擊頂部刷新重試",
"description": "查看請求用量、性能趨勢與調用錯誤記錄",
"dateRangeLabel": "統計日期範圍,僅可選擇最近 31 天",
"requestDetails": "請求明細",
"recordsDescription": "成功請求與失敗請求明細",
"requestDetails": "請求記錄",
"recordsDescription": "成功請求與失敗請求記錄",
"failedToLoad": "加載失敗:{error}",
"noRequestsMatchTheseFilters": "所選條件下暫無請求記錄",
"viewErrorDetails": "查看錯誤詳情",
@@ -309,7 +310,6 @@
"message": "消息",
"upstreamTimeConsumption": "上游耗時",
"storageModel": "存儲模型",
"upstreamReturnModel": "上游返回模型",
"clientIp": "客戶端 IP",
"eventType": "事件類型",
"trySerialNumber": "嘗試序號",
@@ -440,7 +440,7 @@ onBeforeUnmount(() => {
</span>
<template v-for="item in navItems" :key="item.label">
<div v-if="item.path === '/plugins'" class="relative z-10 grid min-w-0">
<ZPopover trigger="hover" placement="right" role="tooltip" :disabled="!isCollapsed" :enterable="false" class="w-full">
<ZPopover trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="w-full">
<template #reference="{ close }">
<button
type="button"
@@ -529,7 +529,7 @@ onBeforeUnmount(() => {
</div>
</div>
<ZPopover v-else trigger="hover" placement="right" role="tooltip" :disabled="!isCollapsed" :enterable="false" class="relative z-10 w-full">
<ZPopover v-else trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="relative z-10 w-full">
<template #reference="{ close }">
<button
type="button"

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