mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 16:05:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d26bc36db5 | ||
|
|
96d5ab674a | ||
|
|
84c0db2613 | ||
|
|
d819315d8b | ||
|
|
06e1d8f0ac | ||
|
|
bdc8274b36 | ||
|
|
3f6607209b | ||
|
|
3c05bc8f07 | ||
|
|
9ccd84f1a0 | ||
|
|
a8f183707e | ||
|
|
3f20af61ff | ||
|
|
efaed4c3a6 |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
¶meters.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,
|
||||
¶meters.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,
|
||||
¶meters.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
@@ -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
@@ -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
@@ -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` 按字面量包含匹配请求模型或上游模型,`%`、`_` 和 `\` 按普通字符处理
|
||||
|
||||
@@ -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
@@ -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 仍由插件仓库固定到包含所需合同的宿主提交
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+15
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "重新加载",
|
||||
|
||||
@@ -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": "重新加載",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user