refactor(usage): remove reported model tracking and polish UI

Remove upstream response model tracking across providers, storage, APIs,
and plugin facts without migrating historical records.

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

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-25 04:03:27 +08:00
co-authored by Codex
parent 3c05bc8f07
commit 3f6607209b
59 changed files with 53 additions and 553 deletions
@@ -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),
@@ -1070,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,
-17
View File
@@ -624,7 +624,6 @@ pub struct ProviderResponseObservation {
status_code: Option<u16>,
request_id: Option<OpaqueUpstreamValue>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
timings: ProviderResponseTimings,
// 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合
client_headers: Arc<[ProviderResponseHeader]>,
@@ -641,7 +640,6 @@ impl ProviderResponseObservation {
status_code: None,
request_id: None,
service_tier: None,
upstream_response_model: None,
timings: ProviderResponseTimings::default(),
client_headers: Arc::default(),
provider_metadata: None,
@@ -697,21 +695,6 @@ impl ProviderResponseObservation {
self
}
/// 记录 Provider 从上游响应明确取得的模型,不改写发送模型或计费合同
#[must_use]
pub fn with_upstream_response_model_if_valid(mut self, model: &str) -> Self {
let model = model.trim();
if validate_text(model, 256, false, None).is_ok() {
self.upstream_response_model = Some(model.to_owned());
}
self
}
#[must_use]
pub fn upstream_response_model(&self) -> Option<&str> {
self.upstream_response_model.as_deref()
}
#[must_use]
pub const fn with_timings(mut self, timings: ProviderResponseTimings) -> Self {
self.timings = timings;
@@ -491,7 +491,6 @@ struct FinalState {
http_version: Option<String>,
websocket_pool: Option<String>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
upstream_request_id: Option<String>,
upstream_status_code: Option<u16>,
transport_decision_wait_ms: Option<u64>,
@@ -682,7 +681,6 @@ impl ExecutionStore for FakeStore {
http_version: finalization.http_version,
websocket_pool: finalization.websocket_pool,
service_tier: finalization.service_tier,
upstream_response_model: finalization.upstream_response_model,
upstream_request_id: finalization.upstream_request_id,
upstream_status_code: finalization.upstream_status_code,
transport_decision_wait_ms: finalization.timings.transport_decision_wait_ms,
@@ -1764,7 +1762,6 @@ fn response_observation_is_persisted_but_never_delivered() {
.with_websocket_pool(WebSocketPoolKind::New)
.with_status_code(200)
.with_request_id(OpaqueUpstreamValue::new("upstream-observed"))
.with_upstream_response_model_if_valid("gpt-returned")
.try_with_service_tier("priority")
.expect("service tier")
.with_timings(ProviderResponseTimings {
@@ -1804,10 +1801,6 @@ fn response_observation_is_persisted_but_never_delivered() {
assert_eq!(finalization.http_version.as_deref(), Some("HTTP/2"));
assert_eq!(finalization.websocket_pool.as_deref(), Some("new"));
assert_eq!(finalization.service_tier.as_deref(), Some("priority"));
assert_eq!(
finalization.upstream_response_model.as_deref(),
Some("gpt-returned")
);
assert_eq!(
finalization.upstream_request_id.as_deref(),
Some("upstream-observed")
@@ -2083,7 +2076,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
.with_http_version(UpstreamHttpVersion::Http11)
.with_status_code(503)
.with_request_id(OpaqueUpstreamValue::new("discarded-request"))
.with_upstream_response_model_if_valid("discarded-model")
.with_timings(ProviderResponseTimings {
transport_decision_wait_ms: Some(7),
connect_ms: Some(11),
@@ -2146,7 +2138,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
let state = store.state.lock().expect("store lock");
let finalization = &state.finalizations[0];
assert_eq!(finalization.upstream_response_model, None);
let trace: Value =
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
let events = trace["events"].as_array().unwrap();
@@ -2282,7 +2282,6 @@ impl Provider for ObservationProvider {
gateway_core::event::ProviderResponseObservation::new(
UpstreamTransport::new("http_sse").unwrap(),
)
.with_upstream_response_model_if_valid("gpt-reported")
.with_service_tier_if_valid("priority"),
);
Ok(ProviderStream::new(
@@ -2615,7 +2614,6 @@ fn final_observation_is_emitted_once_for_success_rejection_and_detached_cancella
.map(UpstreamModelId::as_str),
Some("gpt-start")
);
assert_eq!(observations[0].response_model(), Some("gpt-reported"));
assert_eq!(observations[0].service_tier(), Some("priority"));
assert_eq!(
observations[0].outcome(),
@@ -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,
@@ -189,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"),
@@ -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("{}"))
@@ -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")]
@@ -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");
}
}
@@ -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,
@@ -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()),
@@ -966,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();
@@ -1025,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()
@@ -1103,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)| {
@@ -1196,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
@@ -1266,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
@@ -1302,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);
@@ -1328,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
@@ -1371,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))
}
@@ -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
@@ -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")) {
@@ -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);
@@ -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
@@ -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
@@ -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,
+1 -6
View File
@@ -2099,12 +2099,7 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
详情不出现在 Key 用量接口和普通错误信封中;[诊断导出](#诊断与恢复关联)在服务端重新脱敏后包含已存详情。
记录仍受异步观测写入与保留周期约束
管理端请求列表与详情分别保留 `requestedModel`(客户端请求)、`upstreamModel`(网关发送)与
`upstreamResponseModel`(上游返回)。返回模型缺失时为 `null`,不使用请求或映射模型补齐。
OpenAI 优先采用服务端 `openai-model` / `x-openai-model` 报告(流内报告可覆盖初始响应头),
没有报告时采用正文明确声明的 `response.model`;xAI 采用原始正文声明。正文模型以终态优先,
缺少终态声明时保留首次声明。这些值仅表示上游报告,不作为模型真实性证明,也不参与路由、
聚合或本地计价模型选择。没有保存上游模型报告的记录保持未知
管理端请求列表与详情保留 `requestedModel`(客户端请求)与 `upstreamModel`(网关发送)
请求明细的 `clientApiKeyName`、`accountName` 按字面量包含匹配请求保存的历史名称快照,区分大小写,
不解析为 ID,也不依赖当前配置是否存在。`model` 按字面量包含匹配请求模型或上游模型,`%`、`_` 和 `\` 按普通字符处理
-2
View File
@@ -98,7 +98,6 @@ export interface UsageListRecord {
model: string | null
requestedModel: string | null
upstreamModel: string | null
upstreamResponseModel: string | null
serviceTier: string | null
clientTransport: string
upstreamTransport: string | null
@@ -131,7 +130,6 @@ export interface UsageRecord {
model: string | null
requestedModel: string | null
upstreamModel: string | null
upstreamResponseModel: string | null
serviceTier: string | null
statusCode: number | null
clientTransport: string
+7 -3
View File
@@ -2,7 +2,7 @@
import type { IconButtonProps } from '@codex-proxy/ui'
import type { Locale } from '@/i18n/locale'
import { ZIconButton, ZMenuItem, ZPopover } from '@codex-proxy/ui'
import { Check, Languages } from '@lucide/vue'
import { Check, Languages, LoaderCircle } from '@lucide/vue'
import chineseFlag from 'circle-flags/flags/cn.svg?url&no-inline'
import englishFlag from 'circle-flags/flags/us.svg?url&no-inline'
import { computed, shallowRef, useTemplateRef } from 'vue'
@@ -61,7 +61,8 @@ async function selectLocale(value: Locale, close: () => void) {
v-for="option in localeOptions"
:key="option.value"
:aria-pressed="option.value === locale"
:loading="pendingLocale === option.value"
:disabled="pendingLocale === option.value"
:aria-busy="pendingLocale === option.value || undefined"
class="aria-pressed:bg-cp-control-item-bg-active aria-pressed:text-cp-primary-text aria-pressed:hover:bg-cp-control-item-bg-active"
@click="selectLocale(option.value, close)"
>
@@ -70,7 +71,10 @@ async function selectLocale(value: Locale, close: () => void) {
</template>
<span class="flex items-center justify-between gap-3">
<span :lang="option.value">{{ option.label }}</span>
<Check v-if="option.value === locale" class="size-4 shrink-0" aria-hidden="true" />
<span class="inline-grid size-4 shrink-0 place-items-center" aria-hidden="true">
<LoaderCircle v-if="pendingLocale === option.value" class="size-4 animate-spin motion-reduce:animate-none" />
<Check v-else-if="option.value === locale" class="size-4" />
</span>
</span>
</ZMenuItem>
</div>
@@ -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,7 +6,7 @@ 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: 'model', label: t('usage.fields.model'), kind: 'custom', size: 'lg' },
{ key: 'reasoningEffort', label: t('usage.fields.reasoningEffort'), kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: t('usage.fields.endpoint'), kind: 'mono' },
{ key: 'upstreamTransport', label: t('usage.fields.upstream'), 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) {
+4 -5
View File
@@ -82,6 +82,7 @@
"reviewSubagentRequest": "Review subagent request"
},
"filters": {
"modelPlaceholder": "Filter by model",
"error": "Error",
"requestDetailType": "Request detail type",
"label": "Usage record filters and actions",
@@ -127,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",
@@ -168,6 +167,7 @@
"requestsPerMinute": "RPM"
},
"header": {
"title": "Analytics",
"currentKey": "Current key",
"keyPrefix": "Key prefix",
"keyConfiguration": "Key configuration",
@@ -201,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",
@@ -310,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",
+4 -5
View File
@@ -82,6 +82,7 @@
"reviewSubagentRequest": "Review 子代理请求"
},
"filters": {
"modelPlaceholder": "按模型筛选",
"error": "错误",
"requestDetailType": "请求明细类型",
"label": "使用记录筛选与操作",
@@ -127,8 +128,6 @@
"future": "未来",
"status": {
"mappedModel": "网关映射后发送给上游的模型:{secondary}",
"returnedModel": "上游返回模型:{returned}(与网关映射后发送的模型一致)",
"returnedModelHelp": "上游返回模型:{returned}",
"firstOutputWaiting": "首个输出等待",
"reasoningToText": "推理到正文",
"firstOneIsOutputToText": "首个输出到正文",
@@ -168,6 +167,7 @@
"requestsPerMinute": "RPM"
},
"header": {
"title": "使用统计",
"currentKey": "当前密钥",
"keyPrefix": "密钥前缀",
"keyConfiguration": "密钥配置",
@@ -201,8 +201,8 @@
"clickRefreshAtTopToTryAgain": ",请点击顶部刷新重试",
"description": "查看请求用量、性能趋势与调用错误记录",
"dateRangeLabel": "统计日期范围,仅可选择最近 31 天",
"requestDetails": "请求明细",
"recordsDescription": "成功请求与失败请求明细",
"requestDetails": "请求记录",
"recordsDescription": "成功请求与失败请求记录",
"failedToLoad": "加载失败:{error}",
"noRequestsMatchTheseFilters": "所选条件下暂无请求记录",
"viewErrorDetails": "查看错误详情",
@@ -310,7 +310,6 @@
"message": "消息",
"upstreamTimeConsumption": "上游耗时",
"storageModel": "存储模型",
"upstreamReturnModel": "上游返回模型",
"clientIp": "客户端 IP",
"eventType": "事件类型",
"trySerialNumber": "尝试序号",
+4 -5
View File
@@ -82,6 +82,7 @@
"reviewSubagentRequest": "Review 子代理請求"
},
"filters": {
"modelPlaceholder": "按模型篩選",
"error": "錯誤",
"requestDetailType": "請求明細類型",
"label": "使用記錄篩選與操作",
@@ -127,8 +128,6 @@
"future": "未來",
"status": {
"mappedModel": "網關映射後發送給上游的模型:{secondary}",
"returnedModel": "上游返回模型:{returned}(與網關映射後發送的模型一致)",
"returnedModelHelp": "上游返回模型:{returned}",
"firstOutputWaiting": "首個輸出等待",
"reasoningToText": "推理到正文",
"firstOneIsOutputToText": "首個輸出到正文",
@@ -168,6 +167,7 @@
"requestsPerMinute": "RPM"
},
"header": {
"title": "使用統計",
"currentKey": "當前密鑰",
"keyPrefix": "密鑰前綴",
"keyConfiguration": "密鑰配置",
@@ -201,8 +201,8 @@
"clickRefreshAtTopToTryAgain": ",請點擊頂部刷新重試",
"description": "查看請求用量、性能趨勢與調用錯誤記錄",
"dateRangeLabel": "統計日期範圍,僅可選擇最近 31 天",
"requestDetails": "請求明細",
"recordsDescription": "成功請求與失敗請求明細",
"requestDetails": "請求記錄",
"recordsDescription": "成功請求與失敗請求記錄",
"failedToLoad": "加載失敗:{error}",
"noRequestsMatchTheseFilters": "所選條件下暫無請求記錄",
"viewErrorDetails": "查看錯誤詳情",
@@ -310,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"
@@ -35,7 +35,7 @@ async function logout() {
</script>
<template>
<ZPageHeader :title="t('usage.usage')">
<ZPageHeader :title="t('usage.header.title')">
<template #description>
<span class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
<span class="min-w-0 truncate text-cp font-semibold text-cp-text" :title="name">
+5 -8
View File
@@ -28,14 +28,11 @@ const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig }
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" />
<div class="flex flex-wrap items-center justify-between gap-3">
<span v-if="overview" class="text-cp-sm text-cp-text-tertiary">{{ t('usage.page.updated', { asOfDisplay: overview.asOfDisplay }) }}</span>
<div class="ml-auto flex w-full min-w-0 items-center gap-3 sm:w-md sm:flex-none">
<span id="key-usage-model-label" class="shrink-0 text-cp-sm text-cp-text-secondary">{{ t('usage.fields.model') }}</span>
<ZInput id="key-usage-model" v-model="model" class="min-w-0 flex-1" :placeholder="t('usage.filters.enterFilterValue')" aria-labelledby="key-usage-model-label" :maxlength="128">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</ZInput>
</div>
<ZInput id="key-usage-model" v-model="model" class="ml-auto w-full min-w-0 sm:w-md sm:flex-none" :placeholder="t('usage.filters.modelPlaceholder')" :aria-label="t('usage.filters.modelPlaceholder')" :maxlength="128">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</ZInput>
</div>
</div>
<p v-if="overviewError" role="alert" class="m-0 rounded-cp-lg bg-cp-error-container px-4 py-3 text-cp-sm text-cp-error-text">
@@ -121,7 +121,6 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
model: options.model,
requestedModel: options.model,
upstreamModel: options.model,
upstreamResponseModel: null,
serviceTier: options.provider === 'openai' ? 'priority' : 'standard',
clientTransport: options.clientTransport,
upstreamTransport: options.upstreamTransport,
@@ -85,7 +85,6 @@ const modelRouteItems = computed(() => [
mono: true,
},
{ label: t('usage.request.storageModel'), value: props.record?.model, mono: true },
{ label: t('usage.request.upstreamReturnModel'), value: props.record?.upstreamResponseModel, mono: true },
])
const clientUpstreamItems = computed(() => [