feat(proxy): align latest Responses semantics

Preserve Responses Lite and Memgen controls across HTTP and WebSocket transports without rewriting request bodies, and standardize continuation recovery signals.

Track cache-write usage and billing while applying appcast profile updates to new WebSocket generations without breaking exact continuations.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-17 00:50:27 +08:00
co-authored by Codex
parent d01ea7f775
commit f0ccc981fb
41 changed files with 745 additions and 96 deletions
@@ -353,10 +353,12 @@ pub(super) async fn list_current_window_model_usage(
let input_tokens = row.input_tokens;
let output_tokens = row.output_tokens;
let cached_tokens = row.cached_tokens;
let cache_write_tokens = row.cache_write_tokens;
let billing_amount_usd = billing::calculate_billing_amount(
nonnegative_i64_to_u64(input_tokens),
nonnegative_i64_to_u64(output_tokens),
nonnegative_i64_to_u64(cached_tokens),
nonnegative_i64_to_u64(cache_write_tokens),
&model,
row.service_tier.as_deref(),
);
+25
View File
@@ -121,11 +121,13 @@ pub(crate) struct UsageRecordTokenDetailsData {
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
reasoning_tokens: u64,
total_tokens: u64,
input_tokens_display: String,
output_tokens_display: String,
cached_tokens_display: String,
cache_write_tokens_display: String,
reasoning_tokens_display: String,
total_tokens_display: String,
}
@@ -136,22 +138,26 @@ pub(crate) struct UsageRecordBillingData {
input_amount: f64,
output_amount: f64,
cache_read_amount: f64,
cache_write_amount: f64,
standard_amount: f64,
total_amount: f64,
input_price_per_mtoken: f64,
output_price_per_mtoken: f64,
cache_read_price_per_mtoken: f64,
cache_write_price_per_mtoken: f64,
service_tier: Option<String>,
service_tier_display: String,
multiplier: f64,
input_amount_display: String,
output_amount_display: String,
cache_read_amount_display: String,
cache_write_amount_display: String,
standard_amount_display: String,
total_amount_display: String,
input_price_display: String,
output_price_display: String,
cache_read_price_display: String,
cache_write_price_display: String,
multiplier_display: String,
}
@@ -162,6 +168,7 @@ struct UsageRecordSummaryData {
input_tokens: String,
output_tokens: String,
cached_tokens: String,
cache_write_tokens: String,
total_tokens: String,
average_latency_ms: String,
}
@@ -175,6 +182,7 @@ struct UsageRecordBreakdownData {
input_tokens: String,
output_tokens: String,
cached_tokens: String,
cache_write_tokens: String,
total_tokens: String,
total_tokens_value: u64,
total_tokens_total: String,
@@ -195,6 +203,8 @@ struct UsageRecordTrendPointData {
output_tokens_value: u64,
cached_tokens: String,
cached_tokens_value: u64,
cache_write_tokens: String,
cache_write_tokens_value: u64,
total_tokens: String,
total_tokens_value: u64,
cache_hit_rate: String,
@@ -574,6 +584,10 @@ pub(crate) fn usage_token_details(record: &UsageRecord) -> UsageRecordTokenDetai
.cached_tokens
.and_then(|value| value.try_into().ok())
.unwrap_or(0);
let cache_write_tokens: u64 = record
.cache_write_tokens
.and_then(|value| value.try_into().ok())
.unwrap_or(0);
let reasoning_tokens: u64 = record
.reasoning_tokens
.and_then(|value| value.try_into().ok())
@@ -584,11 +598,13 @@ pub(crate) fn usage_token_details(record: &UsageRecord) -> UsageRecordTokenDetai
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
reasoning_tokens,
total_tokens,
input_tokens_display: format_number(input_tokens),
output_tokens_display: format_number(output_tokens),
cached_tokens_display: format_compact_number(cached_tokens),
cache_write_tokens_display: format_compact_number(cache_write_tokens),
reasoning_tokens_display: format_number(reasoning_tokens),
total_tokens_display: format_number(total_tokens),
}
@@ -605,28 +621,33 @@ pub(crate) fn usage_billing(
tokens.input_tokens,
tokens.output_tokens,
tokens.cached_tokens,
tokens.cache_write_tokens,
)?;
Some(UsageRecordBillingData {
input_amount: billing.input_amount,
output_amount: billing.output_amount,
cache_read_amount: billing.cache_read_amount,
cache_write_amount: billing.cache_write_amount,
standard_amount: billing.standard_amount,
total_amount: billing.total_amount,
input_price_per_mtoken: billing.input_price_per_mtoken,
output_price_per_mtoken: billing.output_price_per_mtoken,
cache_read_price_per_mtoken: billing.cache_read_price_per_mtoken,
cache_write_price_per_mtoken: billing.cache_write_price_per_mtoken,
service_tier: billing.service_tier,
service_tier_display: billing.service_tier_display,
multiplier: billing.multiplier,
input_amount_display: format_billing_amount(billing.input_amount),
output_amount_display: format_billing_amount(billing.output_amount),
cache_read_amount_display: format_billing_amount(billing.cache_read_amount),
cache_write_amount_display: format_billing_amount(billing.cache_write_amount),
standard_amount_display: format_billing_amount(billing.standard_amount),
total_amount_display: format_billing_amount(billing.total_amount),
input_price_display: format_token_price(billing.input_price_per_mtoken),
output_price_display: format_token_price(billing.output_price_per_mtoken),
cache_read_price_display: format_token_price(billing.cache_read_price_per_mtoken),
cache_write_price_display: format_token_price(billing.cache_write_price_per_mtoken),
multiplier_display: format_multiplier(billing.multiplier),
})
}
@@ -638,6 +659,7 @@ impl From<UsageRecordSummary> for UsageRecordSummaryData {
input_tokens: format_tokens(summary.input_tokens),
output_tokens: format_tokens(summary.output_tokens),
cached_tokens: format_tokens(summary.cached_tokens),
cache_write_tokens: format_tokens(summary.cache_write_tokens),
total_tokens: format_tokens(summary.total_tokens),
average_latency_ms: format_duration_ms_f64(summary.average_latency_ms),
}
@@ -659,6 +681,7 @@ impl UsageRecordBreakdownData {
input_tokens: format_tokens(item.input_tokens),
output_tokens: format_tokens(item.output_tokens),
cached_tokens: format_tokens(item.cached_tokens),
cache_write_tokens: format_tokens(item.cache_write_tokens),
total_tokens: format_tokens(item.total_tokens),
total_tokens_value: item.total_tokens,
total_tokens_total: format_tokens(total_tokens_total),
@@ -698,6 +721,8 @@ impl From<UsageRecordTrendPoint> for UsageRecordTrendPointData {
output_tokens_value: point.output_tokens,
cached_tokens: format_tokens(point.cached_tokens),
cached_tokens_value: point.cached_tokens,
cache_write_tokens: format_tokens(point.cache_write_tokens),
cache_write_tokens_value: point.cache_write_tokens,
total_tokens: format_tokens(point.total_tokens),
total_tokens_value: point.total_tokens,
cache_hit_rate: format_percent(cache_hit_rate_value),
+1 -1
View File
@@ -83,7 +83,7 @@ const MODEL_NOT_FOUND_ERROR: OpenAiErrorKind = OpenAiErrorKind {
};
const HISTORY_UNAVAILABLE_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "invalid_request_error",
code: "previous_response_unavailable",
code: "previous_response_not_found",
};
const CONTINUATION_BUSY_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "server_error",
+25 -1
View File
@@ -19,7 +19,13 @@ use crate::{
api::AppState,
api::middleware::request_id::{ClientIp, RequestId},
dispatch::{errors::ResponseDispatchError, service::ResponseDispatchStream},
upstream::openai::protocol::{responses::CodexResponsesRequest, sse::sse_body_has_done},
upstream::openai::protocol::{
responses::{
CodexResponsesRequest, WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY,
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
},
sse::sse_body_has_done,
},
};
use self::sse::{done_sse_frame, event_stream_response as sse_event_stream_response};
@@ -103,11 +109,29 @@ pub fn build_codex_request(
None => {}
}
apply_context_header_fields(&mut request, headers);
apply_request_semantic_headers(&mut request, headers);
apply_session_fields(&mut request, headers);
request.client_user_agent = user_agent_from_headers(headers);
request
}
fn apply_request_semantic_headers(request: &mut CodexResponsesRequest, headers: &HeaderMap) {
request.responses_lite = header_string(headers, X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER)
.or_else(|| {
request
.client_metadata()
.and_then(Value::as_object)
.and_then(|metadata| {
metadata.get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY)
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
});
request.memgen_request = header_string(headers, X_OPENAI_MEMGEN_REQUEST_HEADER);
}
fn apply_session_fields(request: &mut CodexResponsesRequest, headers: &HeaderMap) {
request.client_conversation_id = session_context_string(request.body(), "conversation_id")
.or_else(|| header_string(headers, "conversation-id"))
@@ -545,7 +545,7 @@ fn parse_response_create(payload: &str) -> Result<Map<String, Value>, ResponseCr
Some(Value::String(message_type)) if message_type == "response.create" => {}
_ => return Err(ResponseCreateFrameError::UnsupportedType),
}
if body.get("stream").and_then(Value::as_bool) != Some(true) {
if matches!(body.get("stream"), Some(value) if value.as_bool() != Some(true)) {
return Err(ResponseCreateFrameError::StreamingRequired);
}
Ok(body)
+1 -1
View File
@@ -60,7 +60,7 @@ pub(in crate::dispatch) async fn enter_request(
let catalog = dependencies.models.catalog().await;
let display_model = catalog.resolve_model_id(requested_model);
request.set_model(display_model.clone());
request.set_stream(mode.stream());
debug_assert_eq!(request.stream(), mode.stream());
let compact = request.semantics().compact;
let tuple_schema = request.tuple_schema.clone();
+12 -5
View File
@@ -8,11 +8,18 @@ use tracing::info;
const MIGRATION_LOCK_KEY: i64 = i64::from_be_bytes(*b"CPR-MIGR");
const MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial",
sql: include_str!("migrations/0001_initial.sql"),
}];
const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial",
sql: include_str!("migrations/0001_initial.sql"),
},
Migration {
version: 2,
name: "cache_write_tokens",
sql: include_str!("migrations/0002_cache_write_tokens.sql"),
},
];
struct Migration {
version: i64,
@@ -0,0 +1,7 @@
alter table usage_records
add column cache_write_tokens bigint
check (cache_write_tokens is null or cache_write_tokens >= 0);
alter table request_time_buckets
add column cache_write_tokens bigint not null default 0
check (cache_write_tokens >= 0);
@@ -55,6 +55,7 @@ impl AccountUsageQueryService {
nonnegative_i64_to_u64(bucket.totals.input_tokens),
nonnegative_i64_to_u64(bucket.totals.output_tokens),
nonnegative_i64_to_u64(bucket.totals.cached_tokens),
nonnegative_i64_to_u64(bucket.totals.cache_write_tokens),
&bucket.model,
bucket.service_tier.as_deref(),
);
@@ -159,6 +160,7 @@ pub struct AccountUsageTimeBucket {
pub input_tokens: i64,
pub output_tokens: i64,
pub cached_tokens: i64,
pub cache_write_tokens: i64,
pub first_token_latency_sum: i64,
pub first_token_latency_count: i64,
pub latency_sum: i64,
@@ -178,6 +180,7 @@ impl From<UsageTimeBucketRecord> for AccountUsageTimeBucket {
input_tokens: record.input_tokens,
output_tokens: record.output_tokens,
cached_tokens: record.cached_tokens,
cache_write_tokens: record.cache_write_tokens,
first_token_latency_sum: record.first_token_latency_sum,
first_token_latency_count: record.first_token_latency_count,
latency_sum: record.latency_sum,
+55 -6
View File
@@ -10,6 +10,7 @@ struct ModelPricing {
output_price_per_mtoken_priority: f64,
cache_read_price_per_mtoken: f64,
cache_read_price_per_mtoken_priority: f64,
cache_write_multiplier: f64,
long_input_price_per_mtoken: f64,
long_input_price_per_mtoken_priority: f64,
long_output_price_per_mtoken: f64,
@@ -27,6 +28,7 @@ impl ModelPricing {
output_price_per_mtoken_priority: 0.0,
cache_read_price_per_mtoken: 0.0,
cache_read_price_per_mtoken_priority: 0.0,
cache_write_multiplier: 0.0,
long_input_price_per_mtoken: 0.0,
long_input_price_per_mtoken_priority: 0.0,
long_output_price_per_mtoken: 0.0,
@@ -41,6 +43,11 @@ impl ModelPricing {
self
}
const fn with_cache_write(mut self, multiplier: f64) -> Self {
self.cache_write_multiplier = multiplier;
self
}
const fn with_priority(mut self, input: f64, output: f64, cache_read: f64) -> Self {
self.input_price_per_mtoken_priority = input;
self.output_price_per_mtoken_priority = output;
@@ -78,24 +85,28 @@ const MODEL_PRICING_RULES: &[ModelPricingRule] = &[
model: "gpt-5.6-sol",
pricing: ModelPricing::new(5.0, 30.0)
.with_cache(0.5)
.with_cache_write(1.25)
.with_priority(10.0, 60.0, 1.0),
},
ModelPricingRule {
model: "gpt-5.6-terra",
pricing: ModelPricing::new(2.5, 15.0)
.with_cache(0.25)
.with_cache_write(1.25)
.with_priority(5.0, 30.0, 0.5),
},
ModelPricingRule {
model: "gpt-5.6-luna",
pricing: ModelPricing::new(1.0, 6.0)
.with_cache(0.1)
.with_cache_write(1.25)
.with_priority(2.0, 12.0, 0.2),
},
ModelPricingRule {
model: "gpt-5.6",
pricing: ModelPricing::new(5.0, 30.0)
.with_cache(0.5)
.with_cache_write(1.25)
.with_priority(10.0, 60.0, 1.0),
},
ModelPricingRule {
@@ -181,6 +192,8 @@ pub(crate) struct BillingBreakdown {
pub output_amount: f64,
/// 缓存读取金额。
pub cache_read_amount: f64,
/// 缓存写入金额。
pub cache_write_amount: f64,
/// 应用服务档位倍率前的标准金额。
pub standard_amount: f64,
/// 最终计费金额。
@@ -191,6 +204,8 @@ pub(crate) struct BillingBreakdown {
pub output_price_per_mtoken: f64,
/// 缓存读取单价,美元 / 1M token。
pub cache_read_price_per_mtoken: f64,
/// 缓存写入单价,美元 / 1M token。
pub cache_write_price_per_mtoken: f64,
/// 服务档位。
pub service_tier: Option<String>,
/// 服务档位倍率。
@@ -202,16 +217,24 @@ pub(crate) fn calculate_billing(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
model: &str,
service_tier: Option<&str>,
) -> BillingBreakdown {
let pricing = model_pricing(model).unwrap_or_else(|| {
highest_pricing_for_request(input_tokens, output_tokens, cached_tokens, service_tier)
highest_pricing_for_request(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
service_tier,
)
});
calculate_billing_with_pricing(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
pricing,
service_tier,
)
@@ -221,6 +244,7 @@ fn calculate_billing_with_pricing(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
pricing: ModelPricing,
service_tier: Option<&str>,
) -> BillingBreakdown {
@@ -230,6 +254,7 @@ fn calculate_billing_with_pricing(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
pricing,
service_tier,
is_long,
@@ -240,6 +265,7 @@ fn calculate_billing_with_pricing_for_context(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
pricing: ModelPricing,
service_tier: Option<&str>,
is_long: bool,
@@ -277,26 +303,38 @@ fn calculate_billing_with_pricing_for_context(
}
let cached_tokens = cached_tokens.min(input_tokens);
let uncached_input_tokens = if cache_read_price > 0.0 {
input_tokens - cached_tokens
let billed_cache_read_tokens = if cache_read_price > 0.0 {
cached_tokens
} else {
input_tokens
0
};
let cache_write_price = input_price * pricing.cache_write_multiplier;
let billed_cache_write_tokens = if cache_write_price > 0.0 {
cache_write_tokens.min(input_tokens - billed_cache_read_tokens)
} else {
0
};
let uncached_input_tokens = input_tokens
.saturating_sub(billed_cache_read_tokens)
.saturating_sub(billed_cache_write_tokens);
let input_amount = uncached_input_tokens as f64 / 1_000_000.0 * input_price;
let cache_read_amount = cached_tokens as f64 / 1_000_000.0 * cache_read_price;
let cache_read_amount = billed_cache_read_tokens as f64 / 1_000_000.0 * cache_read_price;
let cache_write_amount = billed_cache_write_tokens as f64 / 1_000_000.0 * cache_write_price;
let output_amount = output_tokens as f64 / 1_000_000.0 * output_price;
let standard_amount = input_amount + cache_read_amount + output_amount;
let standard_amount = input_amount + cache_read_amount + cache_write_amount + output_amount;
let total_amount = standard_amount * tier_multiplier;
BillingBreakdown {
input_amount,
output_amount,
cache_read_amount,
cache_write_amount,
standard_amount,
total_amount,
input_price_per_mtoken: input_price,
output_price_per_mtoken: output_price,
cache_read_price_per_mtoken: cache_read_price,
cache_write_price_per_mtoken: cache_write_price,
service_tier: normalize_service_tier(service_tier),
tier_multiplier,
}
@@ -309,6 +347,7 @@ pub(crate) fn calculate_aggregate_billing(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
model: &str,
service_tier: Option<&str>,
is_long: bool,
@@ -318,6 +357,7 @@ pub(crate) fn calculate_aggregate_billing(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
service_tier,
is_long,
)
@@ -326,6 +366,7 @@ pub(crate) fn calculate_aggregate_billing(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
pricing,
service_tier,
is_long,
@@ -337,6 +378,7 @@ pub(crate) fn calculate_billing_amount(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
model: &str,
service_tier: Option<&str>,
) -> f64 {
@@ -344,6 +386,7 @@ pub(crate) fn calculate_billing_amount(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
model,
service_tier,
)
@@ -365,6 +408,7 @@ fn highest_pricing_for_request(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
service_tier: Option<&str>,
) -> ModelPricing {
MODEL_PRICING_RULES
@@ -374,6 +418,7 @@ fn highest_pricing_for_request(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
highest,
service_tier,
)
@@ -382,6 +427,7 @@ fn highest_pricing_for_request(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
rule.pricing,
service_tier,
)
@@ -398,6 +444,7 @@ fn highest_pricing_for_context(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
service_tier: Option<&str>,
is_long: bool,
) -> ModelPricing {
@@ -408,6 +455,7 @@ fn highest_pricing_for_context(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
highest,
service_tier,
is_long,
@@ -417,6 +465,7 @@ fn highest_pricing_for_context(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
rule.pricing,
service_tier,
is_long,
+16 -2
View File
@@ -18,6 +18,7 @@ select
coalesce(sum(input_tokens), 0)::bigint as input_tokens,
coalesce(sum(output_tokens), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens), 0)::bigint as cache_write_tokens,
coalesce(sum(first_token_latency_sum), 0)::bigint as first_token_latency_sum,
coalesce(sum(first_token_latency_count), 0)::bigint as first_token_latency_count,
coalesce(sum(latency_sum), 0)::bigint as latency_sum,
@@ -36,7 +37,8 @@ select
coalesce(sum(success_count + error_count), 0)::bigint as request_count,
coalesce(sum(input_tokens), 0)::bigint as input_tokens,
coalesce(sum(output_tokens), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens), 0)::bigint as cache_write_tokens
from request_time_buckets
group by bucket_start, model, service_tier";
@@ -59,6 +61,7 @@ pub struct UsageTimeBucketRecord {
pub input_tokens: i64,
pub output_tokens: i64,
pub cached_tokens: i64,
pub cache_write_tokens: i64,
pub first_token_latency_sum: i64,
pub first_token_latency_count: i64,
pub latency_sum: i64,
@@ -81,6 +84,7 @@ pub struct UsageBucketTotals {
pub input_tokens: i64,
pub output_tokens: i64,
pub cached_tokens: i64,
pub cache_write_tokens: i64,
}
/// 保留期内单个计费桶的用量事实。
@@ -97,6 +101,9 @@ impl UsageBucketTotals {
self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
self.cached_tokens = self.cached_tokens.saturating_add(other.cached_tokens);
self.cache_write_tokens = self
.cache_write_tokens
.saturating_add(other.cache_write_tokens);
}
}
@@ -117,6 +124,7 @@ pub struct ModelBucketUsage {
pub input_tokens: i64,
pub output_tokens: i64,
pub cached_tokens: i64,
pub cache_write_tokens: i64,
pub last_used_at: Option<DateTime<Utc>>,
}
@@ -163,6 +171,7 @@ impl PgRequestBucketQuery {
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
cached_tokens: row.get("cached_tokens"),
cache_write_tokens: row.get("cache_write_tokens"),
},
})
.collect())
@@ -198,7 +207,8 @@ impl PgRequestBucketQuery {
coalesce(sum(success_count + error_count), 0)::bigint as request_count,
coalesce(sum(input_tokens), 0)::bigint as input_tokens,
coalesce(sum(output_tokens), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens), 0)::bigint as cache_write_tokens
from request_time_buckets
where account_id in (",
);
@@ -223,6 +233,7 @@ impl PgRequestBucketQuery {
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
cached_tokens: row.get("cached_tokens"),
cache_write_tokens: row.get("cache_write_tokens"),
};
let Some(account_windows) = windows_by_account.get(account_id.as_str()) else {
continue;
@@ -258,6 +269,7 @@ impl PgRequestBucketQuery {
coalesce(sum(input_tokens), 0)::bigint as input_tokens,
coalesce(sum(output_tokens), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens), 0)::bigint as cache_write_tokens,
max(bucket_start) as last_used_at
from request_time_buckets
where model != '__unknown__' and (",
@@ -290,6 +302,7 @@ impl PgRequestBucketQuery {
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
cached_tokens: row.get("cached_tokens"),
cache_write_tokens: row.get("cache_write_tokens"),
last_used_at: row.get("last_used_at"),
}
})
@@ -307,6 +320,7 @@ fn usage_time_bucket_from_row(row: &sqlx::postgres::PgRow) -> UsageTimeBucketRec
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
cached_tokens: row.get("cached_tokens"),
cache_write_tokens: row.get("cache_write_tokens"),
first_token_latency_sum: row.get("first_token_latency_sum"),
first_token_latency_count: row.get("first_token_latency_count"),
latency_sum: row.get("latency_sum"),
+9 -4
View File
@@ -53,15 +53,16 @@ impl PgRequestBucketStore {
r"
insert into request_time_buckets (
bucket_start, provider, account_id, model, service_tier, success_count,
input_tokens, output_tokens, cached_tokens, first_token_latency_sum,
input_tokens, output_tokens, cached_tokens, cache_write_tokens, first_token_latency_sum,
first_token_latency_count, latency_sum, latency_count, max_latency_ms,
min_latency_ms, updated_at
) values ($1, $2, $3, $4, $5, 1, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
) values ($1, $2, $3, $4, $5, 1, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
on conflict (bucket_start, provider, account_id, model, service_tier) do update set
success_count = request_time_buckets.success_count + 1,
input_tokens = request_time_buckets.input_tokens + excluded.input_tokens,
output_tokens = request_time_buckets.output_tokens + excluded.output_tokens,
cached_tokens = request_time_buckets.cached_tokens + excluded.cached_tokens,
cache_write_tokens = request_time_buckets.cache_write_tokens + excluded.cache_write_tokens,
first_token_latency_sum = request_time_buckets.first_token_latency_sum + excluded.first_token_latency_sum,
first_token_latency_count = request_time_buckets.first_token_latency_count + excluded.first_token_latency_count,
latency_sum = request_time_buckets.latency_sum + excluded.latency_sum,
@@ -82,6 +83,7 @@ on conflict (bucket_start, provider, account_id, model, service_tier) do update
.bind(event.input_tokens.unwrap_or(0))
.bind(event.output_tokens.unwrap_or(0))
.bind(event.cached_tokens.unwrap_or(0))
.bind(event.cache_write_tokens.unwrap_or(0))
.bind(first_token.unwrap_or(0))
.bind(i64::from(first_token.is_some()))
.bind(latency.unwrap_or(0))
@@ -188,6 +190,7 @@ with facts as (
coalesce(input_tokens, 0)::bigint as input_tokens,
coalesce(output_tokens, 0)::bigint as output_tokens,
coalesce(cached_tokens, 0)::bigint as cached_tokens,
coalesce(cache_write_tokens, 0)::bigint as cache_write_tokens,
coalesce(first_token_ms, 0)::bigint as first_token_latency_sum,
(first_token_ms is not null)::integer::bigint as first_token_latency_count,
coalesce(latency_ms, 0)::bigint as latency_sum,
@@ -215,6 +218,7 @@ with facts as (
0::bigint,
0::bigint,
0::bigint,
0::bigint,
null::bigint
from ops_error_logs
where created_at >= $1
@@ -226,6 +230,7 @@ with facts as (
sum(input_tokens) as input_tokens,
sum(output_tokens) as output_tokens,
sum(cached_tokens) as cached_tokens,
sum(cache_write_tokens) as cache_write_tokens,
sum(first_token_latency_sum) as first_token_latency_sum,
sum(first_token_latency_count) as first_token_latency_count,
sum(latency_sum) as latency_sum,
@@ -237,13 +242,13 @@ with facts as (
)
insert into request_time_buckets (
bucket_start, provider, account_id, model, service_tier,
success_count, error_count, input_tokens, output_tokens, cached_tokens,
success_count, error_count, input_tokens, output_tokens, cached_tokens, cache_write_tokens,
first_token_latency_sum, first_token_latency_count, latency_sum, latency_count,
max_latency_ms, min_latency_ms, updated_at
)
select
bucket_start, provider, account_id, model, service_tier,
success_count, error_count, input_tokens, output_tokens, cached_tokens,
success_count, error_count, input_tokens, output_tokens, cached_tokens, cache_write_tokens,
first_token_latency_sum, first_token_latency_count, latency_sum, latency_count,
max_latency_ms, min_latency_ms, now()
from aggregated",
+1
View File
@@ -321,6 +321,7 @@ fn lift_success_fact_fields(event: &mut UsageRecord, metadata: &mut Value) {
event.input_tokens = value_nonnegative_i64(usage, "inputTokens");
event.output_tokens = value_nonnegative_i64(usage, "outputTokens");
event.cached_tokens = value_nonnegative_i64(usage, "cachedTokens");
event.cache_write_tokens = value_nonnegative_i64(usage, "cacheWriteTokens");
event.reasoning_tokens = value_nonnegative_i64(usage, "reasoningTokens");
}
+15 -5
View File
@@ -21,7 +21,7 @@ success_terminals as (
client_api_key_id, provider, account_id, transport, model_name,
null::text as failure_class, false as is_client_cancelled,
false as is_caller_error, latency_ms, first_token_ms, billing_model,
service_tier, input_tokens, output_tokens, cached_tokens
service_tier, input_tokens, output_tokens, cached_tokens, cache_write_tokens
from (
select
case when request_id is null then 'event:' || id else 'request:' || request_id end as request_key,
@@ -31,7 +31,7 @@ success_terminals as (
coalesce(nullif(upstream_model, ''), model) as model_name,
latency_ms, first_token_ms,
coalesce(nullif(upstream_model, ''), model) as billing_model,
service_tier, input_tokens, output_tokens, cached_tokens
service_tier, input_tokens, output_tokens, cached_tokens, cache_write_tokens
from usage_records
where created_at >= $1 and created_at < $2
) candidates
@@ -45,7 +45,7 @@ error_terminals as (
latency_ms, null::bigint as first_token_ms,
null::text as billing_model, null::text as service_tier,
null::bigint as input_tokens, null::bigint as output_tokens,
null::bigint as cached_tokens
null::bigint as cached_tokens, null::bigint as cache_write_tokens
from (
select
case when request_id is null then 'event:' || id else 'request:' || request_id end as request_key,
@@ -660,7 +660,8 @@ async fn load_cost(
)::bigint as cache_hit_count,\n\
coalesce(sum(input_tokens), 0)::bigint as input_tokens,\n\
coalesce(sum(output_tokens), 0)::bigint as output_tokens,\n\
coalesce(sum(least(coalesce(cached_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cached_tokens\n\
coalesce(sum(least(coalesce(cached_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cached_tokens,\n\
coalesce(sum(least(coalesce(cache_write_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cache_write_tokens\n\
from usage_records\n\
where created_at >= $1 and created_at < $2\n\
group by bucket_start, billing_model, service_tier, is_long\n\
@@ -735,7 +736,8 @@ async fn load_diagnostic_costs(
)::bigint as cache_hit_count,\n\
coalesce(sum(input_tokens), 0)::bigint as input_tokens,\n\
coalesce(sum(output_tokens), 0)::bigint as output_tokens,\n\
coalesce(sum(least(coalesce(cached_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cached_tokens\n\
coalesce(sum(least(coalesce(cached_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cached_tokens,\n\
coalesce(sum(least(coalesce(cache_write_tokens, 0), coalesce(input_tokens, 0))), 0)::bigint as cache_write_tokens\n\
from {source}\n\
where terminal_requests.is_success\n\
group by name, billing_model, service_tier, is_long"
@@ -764,6 +766,7 @@ struct CostSample {
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
estimated_cost: f64,
standard_cost: f64,
}
@@ -776,6 +779,7 @@ struct CostAccumulator {
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
estimated_cost: f64,
standard_cost: f64,
}
@@ -790,6 +794,9 @@ impl CostAccumulator {
self.input_tokens = self.input_tokens.saturating_add(sample.input_tokens);
self.output_tokens = self.output_tokens.saturating_add(sample.output_tokens);
self.cached_tokens = self.cached_tokens.saturating_add(sample.cached_tokens);
self.cache_write_tokens = self
.cache_write_tokens
.saturating_add(sample.cache_write_tokens);
self.estimated_cost += sample.estimated_cost;
self.standard_cost += sample.standard_cost;
}
@@ -803,12 +810,14 @@ fn cost_sample(row: &sqlx::postgres::PgRow) -> CostSample {
let input_tokens = nonnegative(row.get("input_tokens"));
let output_tokens = nonnegative(row.get("output_tokens"));
let cached_tokens = nonnegative(row.get("cached_tokens")).min(input_tokens);
let cache_write_tokens = nonnegative(row.get("cache_write_tokens")).min(input_tokens);
let billing_model: String = row.get("billing_model");
let service_tier: Option<String> = row.get("service_tier");
let billing = calculate_aggregate_billing(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
&billing_model,
service_tier.as_deref(),
row.get("is_long"),
@@ -820,6 +829,7 @@ fn cost_sample(row: &sqlx::postgres::PgRow) -> CostSample {
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
estimated_cost: billing.total_amount,
standard_cost: billing.standard_amount,
}
+50 -22
View File
@@ -91,11 +91,13 @@ pub(crate) struct UsageRecordBilling {
pub input_amount: f64,
pub output_amount: f64,
pub cache_read_amount: f64,
pub cache_write_amount: f64,
pub standard_amount: f64,
pub total_amount: f64,
pub input_price_per_mtoken: f64,
pub output_price_per_mtoken: f64,
pub cache_read_price_per_mtoken: f64,
pub cache_write_price_per_mtoken: f64,
pub service_tier: Option<String>,
pub service_tier_display: String,
pub multiplier: f64,
@@ -278,8 +280,9 @@ pub(crate) fn usage_record_billing(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
) -> Option<UsageRecordBilling> {
if input_tokens == 0 && output_tokens == 0 && cached_tokens == 0 {
if input_tokens == 0 && output_tokens == 0 && cached_tokens == 0 && cache_write_tokens == 0 {
return None;
}
@@ -293,6 +296,7 @@ pub(crate) fn usage_record_billing(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
model,
service_tier,
);
@@ -301,11 +305,13 @@ pub(crate) fn usage_record_billing(
input_amount: breakdown.input_amount,
output_amount: breakdown.output_amount,
cache_read_amount: breakdown.cache_read_amount,
cache_write_amount: breakdown.cache_write_amount,
standard_amount: breakdown.standard_amount,
total_amount: breakdown.total_amount,
input_price_per_mtoken: breakdown.input_price_per_mtoken,
output_price_per_mtoken: breakdown.output_price_per_mtoken,
cache_read_price_per_mtoken: breakdown.cache_read_price_per_mtoken,
cache_write_price_per_mtoken: breakdown.cache_write_price_per_mtoken,
service_tier: breakdown.service_tier.clone(),
service_tier_display: breakdown
.service_tier
@@ -346,6 +352,7 @@ select
coalesce(sum(input_tokens), 0)::bigint as input_tokens,
coalesce(sum(output_tokens), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens), 0)::bigint as cache_write_tokens,
avg(latency_ms::double precision) as average_latency_ms
from usage_records",
);
@@ -354,11 +361,13 @@ from usage_records",
let input_tokens = nonnegative(row.get("input_tokens"));
let output_tokens = nonnegative(row.get("output_tokens"));
let cached_tokens = nonnegative(row.get("cached_tokens"));
let cache_write_tokens = nonnegative(row.get("cache_write_tokens"));
Ok(UsageRecordSummary {
total_requests: nonnegative(row.get("total_requests")),
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
total_tokens: input_tokens + output_tokens,
average_latency_ms: row.get("average_latency_ms"),
})
@@ -448,6 +457,7 @@ pub(super) async fn usage_breakdown(
coalesce(sum(input_tokens), 0)::bigint as input_tokens,
coalesce(sum(output_tokens), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens), 0)::bigint as cache_write_tokens,
coalesce(sum(latency_ms), 0)::bigint as latency_sum,
count(latency_ms) as latency_count
from usage_records",
@@ -462,6 +472,7 @@ from usage_records",
let input_tokens = nonnegative(row.get("input_tokens"));
let output_tokens = nonnegative(row.get("output_tokens"));
let cached_tokens = nonnegative(row.get("cached_tokens"));
let cache_write_tokens = nonnegative(row.get("cache_write_tokens"));
let request_count = nonnegative(row.get("request_count"));
let latency_sum = nonnegative(row.get("latency_sum"));
let latency_count = nonnegative(row.get("latency_count"));
@@ -469,6 +480,7 @@ from usage_records",
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
&row.get::<String, _>("billing_model"),
row.get::<Option<String>, _>("service_tier").as_deref(),
);
@@ -480,6 +492,7 @@ from usage_records",
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
billing_amount,
latency_sum,
latency_count,
@@ -512,6 +525,7 @@ select
coalesce(sum(input_tokens), 0)::bigint as input_tokens,
coalesce(sum(output_tokens), 0)::bigint as output_tokens,
coalesce(sum(cached_tokens), 0)::bigint as cached_tokens,
coalesce(sum(cache_write_tokens), 0)::bigint as cache_write_tokens,
coalesce(sum(latency_ms), 0)::bigint as latency_sum,
count(latency_ms) as latency_count
from usage_records",
@@ -525,23 +539,26 @@ from usage_records",
let input_tokens = nonnegative(row.get("input_tokens"));
let output_tokens = nonnegative(row.get("output_tokens"));
let cached_tokens = nonnegative(row.get("cached_tokens"));
let cache_write_tokens = nonnegative(row.get("cache_write_tokens"));
let billing_amount = usage_breakdown_billing_amount(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
&row.get::<String, _>("billing_model"),
row.get::<Option<String>, _>("service_tier").as_deref(),
);
days.entry(date.clone())
.or_insert_with(|| UsageTrendAccumulator::new(date))
.push(
.push(UsageTrendSample {
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
billing_amount,
nonnegative(row.get("latency_sum")),
nonnegative(row.get("latency_count")),
);
latency_sum: nonnegative(row.get("latency_sum")),
latency_count: nonnegative(row.get("latency_count")),
});
}
let mut points = days
.into_values()
@@ -576,6 +593,7 @@ pub(super) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> UsageRecord
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
cached_tokens: row.get("cached_tokens"),
cache_write_tokens: row.get("cache_write_tokens"),
reasoning_tokens: row.get("reasoning_tokens"),
message: row.get("message"),
metadata: row
@@ -593,6 +611,7 @@ fn usage_breakdown_billing_amount(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
model: &str,
service_tier: Option<&str>,
) -> f64 {
@@ -600,6 +619,7 @@ fn usage_breakdown_billing_amount(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
model,
service_tier,
)
@@ -616,6 +636,7 @@ struct BreakdownSample {
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
billing_amount: f64,
latency_sum: u64,
latency_count: u64,
@@ -638,6 +659,7 @@ impl BreakdownAccumulator {
self.item.input_tokens += sample.input_tokens;
self.item.output_tokens += sample.output_tokens;
self.item.cached_tokens += sample.cached_tokens;
self.item.cache_write_tokens += sample.cache_write_tokens;
self.item.total_tokens += sample.input_tokens + sample.output_tokens;
self.item.standard_billing_amount += sample.billing_amount;
self.item.actual_billing_amount += sample.billing_amount;
@@ -659,6 +681,16 @@ struct UsageTrendAccumulator {
latency_count: u64,
}
struct UsageTrendSample {
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
billing_amount: f64,
latency_sum: u64,
latency_count: u64,
}
impl UsageTrendAccumulator {
fn new(date: String) -> Self {
Self {
@@ -671,23 +703,16 @@ impl UsageTrendAccumulator {
}
}
fn push(
&mut self,
input: u64,
output: u64,
cached: u64,
billing_amount: f64,
latency_sum: u64,
latency_count: u64,
) {
self.point.input_tokens += input;
self.point.output_tokens += output;
self.point.cached_tokens += cached;
self.point.total_tokens += input + output;
self.point.standard_billing_amount += billing_amount;
self.point.actual_billing_amount += billing_amount;
self.latency_sum += latency_sum;
self.latency_count += latency_count;
fn push(&mut self, sample: UsageTrendSample) {
self.point.input_tokens += sample.input_tokens;
self.point.output_tokens += sample.output_tokens;
self.point.cached_tokens += sample.cached_tokens;
self.point.cache_write_tokens += sample.cache_write_tokens;
self.point.total_tokens += sample.input_tokens + sample.output_tokens;
self.point.standard_billing_amount += sample.billing_amount;
self.point.actual_billing_amount += sample.billing_amount;
self.latency_sum += sample.latency_sum;
self.latency_count += sample.latency_count;
}
fn finish(mut self) -> UsageRecordTrendPoint {
@@ -703,6 +728,7 @@ pub struct UsageRecordSummary {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub cache_write_tokens: u64,
pub total_tokens: u64,
pub average_latency_ms: Option<f64>,
}
@@ -730,6 +756,7 @@ pub struct UsageRecordBreakdown {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub cache_write_tokens: u64,
pub total_tokens: u64,
pub standard_billing_amount: f64,
pub actual_billing_amount: f64,
@@ -743,6 +770,7 @@ pub struct UsageRecordTrendPoint {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub cache_write_tokens: u64,
pub total_tokens: u64,
pub standard_billing_amount: f64,
pub actual_billing_amount: f64,
+6 -3
View File
@@ -24,7 +24,8 @@ select
id, request_id, client_api_key_id, kind, route, provider, account_id, model,
requested_model, upstream_model, service_tier, status_code, transport, attempt_index,
response_id, upstream_request_id, latency_ms, first_token_ms, input_tokens,
output_tokens, cached_tokens, reasoning_tokens, message, metadata_json, created_at
output_tokens, cached_tokens, cache_write_tokens, reasoning_tokens, message, metadata_json,
created_at
from usage_records";
#[derive(Debug, Error)]
@@ -82,10 +83,11 @@ insert into usage_records (
id, request_id, client_api_key_id, kind, route, provider, account_id, model,
requested_model, upstream_model, service_tier, status_code, transport, attempt_index,
response_id, upstream_request_id, latency_ms, first_token_ms, input_tokens,
output_tokens, cached_tokens, reasoning_tokens, message, metadata_json, created_at
output_tokens, cached_tokens, cache_write_tokens, reasoning_tokens, message, metadata_json,
created_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
)",
)
.bind(&event.id)
@@ -109,6 +111,7 @@ insert into usage_records (
.bind(event.input_tokens)
.bind(event.output_tokens)
.bind(event.cached_tokens)
.bind(event.cache_write_tokens)
.bind(event.reasoning_tokens)
.bind(&event.message)
.bind(sqlx::types::Json(&event.metadata))
+3
View File
@@ -65,6 +65,8 @@ pub struct UsageRecord {
pub output_tokens: Option<i64>,
/// 缓存 token。
pub cached_tokens: Option<i64>,
/// 缓存写入 token。
pub cache_write_tokens: Option<i64>,
/// reasoning token。
pub reasoning_tokens: Option<i64>,
/// 展示消息。
@@ -107,6 +109,7 @@ impl UsageRecord {
input_tokens: None,
output_tokens: None,
cached_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
message: message.into(),
metadata: Value::Object(Map::default()),
@@ -15,6 +15,8 @@ pub struct TokenUsage {
pub output_tokens: u64,
/// 命中缓存的输入 token 数。
pub cached_tokens: u64,
/// 写入 prompt cache 的输入 token 数。
pub cache_write_tokens: u64,
/// 输出 token 中的 reasoning token 数。
pub reasoning_tokens: u64,
/// 图片工具输入 token 数。
@@ -44,6 +46,13 @@ pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
.or_else(|| nested_number_field(usage, &["prompt_tokens_details", "cached_tokens"]))
.or_else(|| number_field(usage, "cached_tokens"))
.unwrap_or_default();
let cache_write_tokens =
nested_number_field(usage, &["input_tokens_details", "cache_write_tokens"])
.or_else(|| {
nested_number_field(usage, &["prompt_tokens_details", "cache_write_tokens"])
})
.or_else(|| number_field(usage, "cache_write_tokens"))
.unwrap_or_default();
let reasoning_tokens =
nested_number_field(usage, &["output_tokens_details", "reasoning_tokens"])
.or_else(|| {
@@ -64,6 +73,7 @@ pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
"prompt_tokens",
"completion_tokens",
"cached_tokens",
"cache_write_tokens",
"reasoning_tokens",
"total_tokens",
]
@@ -80,6 +90,7 @@ pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
reasoning_tokens,
image_input_tokens,
image_output_tokens,
@@ -13,6 +13,15 @@ const MULTI_AGENT_MODE_OPEN_TAG: &str = "<multi_agent_mode>";
const MULTI_AGENT_MODE_CLOSE_TAG: &str = "</multi_agent_mode>";
const PROACTIVE_MULTI_AGENT_MODE_PREFIX: &str = "Proactive multi-agent delegation is active.";
/// Codex Responses Lite 的 HTTP 兼容头。
pub const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER: &str =
"x-openai-internal-codex-responses-lite";
/// Codex Responses Lite 在 WebSocket `client_metadata` 中的官方投影键。
pub const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str =
"ws_request_header_x_openai_internal_codex_responses_lite";
/// Codex memory consolidation 请求标记。
pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request";
/// Codex Responses 上游请求体。
///
/// 发往上游的 Responses 请求。`body` 持有客户端原始 JSON object,逐字段(含顺序、
@@ -64,6 +73,10 @@ pub struct CodexResponsesRequest {
pub version: Option<String>,
/// timing metrics 透传头。
pub include_timing_metrics: Option<String>,
/// Responses Lite 请求语义;HTTP 使用 header,WebSocket 使用 client metadata 投影。
pub responses_lite: Option<String>,
/// Memory consolidation 请求语义;HTTP 与 WebSocket opening 均使用 header。
pub memgen_request: Option<String>,
/// codex window id。
pub codex_window_id: Option<String>,
/// 父线程 ID。
@@ -261,6 +274,14 @@ pub fn response_event_signals(event_type: Option<&str>, value: &Value) -> Respon
signals.text_output = non_empty_string(value.get("text"));
signals.semantic_output = signals.text_output;
}
Some("response.refusal.delta") => {
signals.text_output = non_empty_string(value.get("delta"));
signals.semantic_output = signals.text_output;
}
Some("response.refusal.done") => {
signals.text_output = non_empty_string(value.get("refusal"));
signals.semantic_output = signals.text_output;
}
Some("response.reasoning_summary_text.delta" | "response.reasoning_text.delta") => {
signals.reasoning_output = non_empty_string(value.get("delta"));
signals.semantic_output = signals.reasoning_output;
@@ -295,6 +316,26 @@ pub fn response_event_signals(event_type: Option<&str>, value: &Value) -> Respon
merge_output_signals(&mut signals, output_items_signals(items));
}
}
Some(event_type) if event_type.ends_with(".delta") => {
signals.semantic_output = non_empty_semantic_value(value.get("delta"));
}
Some(event_type) if event_type.ends_with(".done") => {
signals.semantic_output = [
"text",
"refusal",
"arguments",
"input",
"transcript",
"data",
"output",
"result",
]
.into_iter()
.any(|field| non_empty_semantic_value(value.get(field)));
}
Some(event_type) if is_tool_execution_event(event_type) => {
signals.semantic_output = true;
}
_ => {}
}
signals
@@ -358,19 +399,18 @@ fn output_item_signals(item: &Value) -> ResponseEventSignals {
signals.semantic_output = signals.text_output;
}
Some("reasoning") => {
signals.reasoning_output =
non_empty_string(item.get("text")) || non_empty_string(item.get("summary"));
signals.reasoning_output = non_empty_semantic_value(item.get("text"))
|| non_empty_semantic_value(item.get("summary"));
signals.semantic_output = signals.reasoning_output;
}
Some("function_call") => {
signals.semantic_output = non_empty_string(item.get("arguments"));
Some("refusal") => {
signals.text_output =
non_empty_string(item.get("refusal")) || non_empty_string(item.get("text"));
signals.semantic_output = signals.text_output;
}
Some("custom_tool_call") => {
signals.semantic_output = non_empty_string(item.get("input"));
}
Some("image_generation_call") => {
signals.semantic_output =
non_empty_string(item.get("result")) || non_empty_string(item.get("image_base64"));
Some(item_type) if item_type.ends_with("_call") => {
// done 的工具调用本身已进入不可安全重试的语义边界;不依赖每种工具的字段表。
signals.semantic_output = true;
}
_ => {
if let Some(items) = item.get("content").and_then(Value::as_array) {
@@ -387,6 +427,30 @@ fn non_empty_string(value: Option<&Value>) -> bool {
.is_some_and(|value| !value.is_empty())
}
fn non_empty_semantic_value(value: Option<&Value>) -> bool {
match value {
Some(Value::String(value)) => !value.is_empty(),
Some(Value::Array(value)) => !value.is_empty(),
Some(Value::Object(value)) => !value.is_empty(),
Some(Value::Number(_) | Value::Bool(_)) => true,
Some(Value::Null) | None => false,
}
}
fn is_tool_execution_event(event_type: &str) -> bool {
let Some((call_type, phase)) = event_type
.strip_prefix("response.")
.and_then(|event| event.rsplit_once('.'))
else {
return false;
};
call_type.ends_with("_call")
&& matches!(
phase,
"in_progress" | "searching" | "interpreting" | "completed" | "failed"
)
}
fn complete_sse_body_prefix(body: &str) -> Option<&str> {
let lf_end = body.rfind("\n\n").map(|index| index + 2);
let crlf_end = body.rfind("\r\n\r\n").map(|index| index + 4);
@@ -669,6 +733,8 @@ impl CodexResponsesRequest {
beta_features: None,
version: None,
include_timing_metrics: None,
responses_lite: None,
memgen_request: None,
codex_window_id: None,
parent_thread_id: None,
previous_response_scope: None,
@@ -76,6 +76,8 @@ struct ResponseCompletedUsage {
struct ResponseCompletedInputTokensDetails {
#[serde(rename = "cached_tokens")]
_cached_tokens: i64,
#[serde(default, rename = "cache_write_tokens")]
_cache_write_tokens: i64,
}
#[derive(Debug, Deserialize)]
@@ -24,7 +24,8 @@ use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use crate::upstream::openai::profile::CodexWireProfileState;
use crate::upstream::openai::protocol::events::{extract_sse_usage, retry_after_seconds_from_body};
use crate::upstream::openai::protocol::responses::{
CodexResponsesRequest, TransportRequirement, transport_requirement,
CodexResponsesRequest, TransportRequirement,
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, transport_requirement,
};
use crate::upstream::openai::protocol::sse::SseError;
use crate::upstream::openai::protocol::websocket::{
@@ -549,10 +550,26 @@ fn is_model_entry(value: &Value) -> bool {
fn websocket_upstream_request(request: &CodexResponsesRequest) -> CodexResponsesRequest {
let mut request = request.clone();
project_responses_lite_to_ws_metadata(&mut request);
stamp_ws_stream_request_start_ms(&mut request);
request
}
fn project_responses_lite_to_ws_metadata(request: &mut CodexResponsesRequest) {
let Some(responses_lite) = request.responses_lite.clone() else {
return;
};
let mut metadata = match request.client_metadata() {
Some(Value::Object(metadata)) => metadata.clone(),
None => Map::new(),
Some(_) => return,
};
metadata
.entry(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string())
.or_insert(Value::String(responses_lite));
request.set_client_metadata(Some(Value::Object(metadata)));
}
fn stamp_ws_stream_request_start_ms(request: &mut CodexResponsesRequest) {
let mut metadata = match request.client_metadata() {
Some(Value::Object(metadata)) => metadata.clone(),
@@ -268,7 +268,7 @@ impl CodexBackendClient {
}
let websocket_request = websocket_upstream_request(request);
let headers = self.request_headers_for_http_response(&websocket_request, context)?;
let headers = self.request_headers_for_websocket_response(&websocket_request, context)?;
let websocket_create = CodexWebSocketConnection::responses_create_request(
&self.base_url,
&generate_key(),
@@ -284,7 +284,9 @@ impl CodexBackendClient {
if let Err(error) = write_websocket_audit_artifact_from_env(&artifact).await {
tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact");
}
let pool_key = self.websocket_pool_key(request, context, pool_account_id);
let connection_profile = websocket_connection_profile(&headers);
let pool_key =
self.websocket_pool_key(request, context, pool_account_id, &connection_profile);
let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key);
let pool = self.websocket_pool.as_deref().zip(pool_key);
let fast_path_budget = match requirement {
@@ -498,17 +500,17 @@ impl CodexBackendClient {
request: &CodexResponsesRequest,
context: CodexRequestContext<'_>,
pool_account_id: Option<&str>,
connection_profile: &str,
) -> Option<CodexWebSocketPoolKey> {
let account_id = pool_account_id.or(context.account_id)?;
let conversation_id = request
.local_conversation_id
.as_deref()
.or(request.previous_response_id())?;
Some(CodexWebSocketPoolKey::new(
&self.base_url,
account_id,
conversation_id,
))
Some(
CodexWebSocketPoolKey::new(&self.base_url, account_id, conversation_id)
.with_connection_profile(connection_profile),
)
}
/// 获取后端模型目录条目。
@@ -584,6 +586,28 @@ impl CodexBackendClient {
HeaderValue::from_str(&subagent)?,
);
}
insert_optional_header(
&mut headers,
crate::upstream::openai::protocol::responses::X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER,
request.responses_lite.as_deref(),
)?;
insert_optional_header(
&mut headers,
crate::upstream::openai::protocol::responses::X_OPENAI_MEMGEN_REQUEST_HEADER,
request.memgen_request.as_deref(),
)?;
Ok(headers)
}
fn request_headers_for_websocket_response(
&self,
request: &CodexResponsesRequest,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let mut headers = self.request_headers_for_http_response(request, context)?;
headers.remove(
crate::upstream::openai::protocol::responses::X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER,
);
Ok(headers)
}
@@ -678,6 +702,21 @@ impl CodexBackendClient {
}
}
fn websocket_connection_profile(headers: &HeaderMap) -> String {
[
"originator",
"user-agent",
crate::upstream::openai::protocol::responses::X_OPENAI_MEMGEN_REQUEST_HEADER,
]
.map(|name| {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
})
.join("\0")
}
fn http_sse_stream(response: ReqwestResponse) -> CodexBackendSseStream {
let stream: CodexBackendSseStream =
Box::pin(response.bytes_stream().map_err(CodexClientError::Http));
@@ -484,7 +484,11 @@ pub(crate) async fn prepare_response_create_request_with_pool(
.await;
};
match pool.acquire(&key).await {
let allow_profile_mismatch = matches!(
request.continuation(),
WebSocketContinuationRequirement::ConnectionLocal { .. }
);
match pool.acquire(&key, allow_profile_mismatch).await {
WebSocketPoolAcquire::Reused { connection, lease } => {
if let WebSocketContinuationRequirement::ConnectionLocal { response_id } =
request.continuation()
@@ -29,6 +29,7 @@ pub struct CodexWebSocketPoolKey {
base_url: String,
account_id: String,
conversation_id: String,
connection_profile: String,
}
impl CodexWebSocketPoolKey {
@@ -42,9 +43,16 @@ impl CodexWebSocketPoolKey {
base_url: base_url.into().trim_end_matches('/').to_string(),
account_id: account_id.into(),
conversation_id: conversation_id.into(),
connection_profile: String::new(),
}
}
/// 区分实际 WebSocket opening 画像,防止复用旧 UA 或不同握手语义的连接。
pub(crate) fn with_connection_profile(mut self, connection_profile: impl Into<String>) -> Self {
self.connection_profile = connection_profile.into();
self
}
pub(crate) fn account_id(&self) -> &str {
&self.account_id
}
@@ -58,8 +66,15 @@ impl CodexWebSocketPoolKey {
self.base_url.as_str(),
self.account_id.as_str(),
self.conversation_id.as_str(),
self.connection_profile.as_str(),
])
}
fn same_logical_connection(&self, other: &Self) -> bool {
self.base_url == other.base_url
&& self.account_id == other.account_id
&& self.conversation_id == other.conversation_id
}
}
/// WebSocket 连接池。
@@ -166,14 +181,28 @@ impl CodexWebSocketPool {
drop(self.tasks.spawn(future));
}
pub(crate) async fn acquire(&self, key: &CodexWebSocketPoolKey) -> WebSocketPoolAcquire {
pub(crate) async fn acquire(
&self,
key: &CodexWebSocketPoolKey,
allow_profile_mismatch: bool,
) -> WebSocketPoolAcquire {
let mut expired_connection = None;
let acquire = {
let mut state = self.inner.lock().await;
if !self.config.enabled || state.shutting_down {
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Disabled);
}
match state.slots.get(key) {
let key = if allow_profile_mismatch && !state.slots.contains_key(key) {
state
.slots
.keys()
.find(|candidate| candidate.same_logical_connection(key))
.cloned()
.unwrap_or_else(|| key.clone())
} else {
key.clone()
};
match state.slots.get(&key) {
Some(WebSocketPoolSlot::Busy(_)) => {
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Busy);
}
@@ -184,7 +213,7 @@ impl CodexWebSocketPool {
});
}
Some(WebSocketPoolSlot::Idle(_)) => {
let Some(WebSocketPoolSlot::Idle(connection)) = state.slots.remove(key) else {
let Some(WebSocketPoolSlot::Idle(connection)) = state.slots.remove(&key) else {
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Busy);
};
// 零成本探活:后台 pump 已实时感知连接死亡(RST/Close/EOF/失活),
@@ -210,7 +239,7 @@ impl CodexWebSocketPool {
} else {
let lease = WebSocketPoolConnectLease::reserve(self.clone(), key.clone());
state.slots.insert(
key.clone(),
key,
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
id: lease.id,
started_at: lease.started_at,
+11 -1
View File
@@ -105,18 +105,21 @@ async fn usage_summary_and_trend_expose_only_real_success_metrics() {
record.input_tokens = Some(100);
record.output_tokens = Some(20);
record.cached_tokens = Some(30);
record.cache_write_tokens = Some(10);
record.latency_ms = Some(10);
usage.append(&record).await.unwrap();
let summary = admin_get(&app, "/api/admin/usage/records/summary").await;
assert_eq!(summary["data"]["totalRequests"], "1");
assert_eq!(summary["data"]["totalTokens"], "120");
assert_eq!(summary["data"]["cacheWriteTokens"], "10");
assert!(summary["data"].get("errorRequests").is_none());
assert!(summary["data"].get("errorRate").is_none());
let trend = admin_get(&app, "/api/admin/usage/records/insights/token-trend").await;
assert_eq!(trend["data"][0]["inputTokensValue"], 100);
assert_eq!(trend["data"][0]["cacheHitRateValue"], 30.0);
assert_eq!(trend["data"][0]["cacheWriteTokensValue"], 10);
assert!(trend["data"][0].get("cacheCreationTokens").is_none());
}
@@ -252,6 +255,7 @@ async fn usage_route_applies_official_gpt_5_6_sol_pricing() {
record.model = "gpt-5.6-sol-2026-07-01".to_string();
record.input_tokens = Some(1_000_000);
record.cached_tokens = Some(200_000);
record.cache_write_tokens = Some(100_000);
record.output_tokens = Some(1_000_000);
usage.append(&record).await.unwrap();
@@ -260,8 +264,14 @@ async fn usage_route_applies_official_gpt_5_6_sol_pricing() {
assert_eq!(billing["inputPricePerMtoken"], 5.0);
assert_eq!(billing["cacheReadPricePerMtoken"], 0.5);
assert_eq!(billing["cacheWritePricePerMtoken"], 6.25);
assert_eq!(billing["cacheWriteAmount"], 0.625);
assert_eq!(billing["outputPricePerMtoken"], 30.0);
assert_eq!(billing["totalAmount"], 34.1);
assert_eq!(billing["totalAmount"], 34.225);
assert_eq!(
body["data"]["items"][0]["tokenDetails"]["cacheWriteTokens"],
100_000
);
}
#[tokio::test]
@@ -231,6 +231,36 @@ async fn responses_websocket_should_forward_multiple_response_create_requests_on
assert_eq!(upstream_requests[1]["type"], "response.create");
}
#[tokio::test]
async fn responses_websocket_should_accept_omitted_stream_without_injecting_it() {
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
let upstream = tokio::spawn(async move {
let (stream, _) = upstream_listener.accept().await.unwrap();
let mut websocket = accept_upstream_websocket(stream).await.unwrap();
let request = websocket.next().await.unwrap().unwrap();
let request = serde_json::from_str::<Value>(&request.into_text().unwrap()).unwrap();
send_upstream_response(&mut websocket, "resp_omitted_stream", "ok").await;
request
});
let (app, api_key, _dir) =
crate::dispatch::service::test_app_with_account(upstream_base_url).await;
let server = spawn_app(app).await;
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
let mut payload: Value = serde_json::from_str(&response_create_payload("hello")).unwrap();
payload.as_object_mut().unwrap().remove("stream");
websocket
.send(Message::Text(payload.to_string().into()))
.await
.unwrap();
let response = receive_response(&mut websocket).await;
let upstream_request = upstream.await.unwrap();
assert_eq!(response.last().unwrap()["type"], "response.completed");
assert!(upstream_request.get("stream").is_none());
}
#[tokio::test]
async fn responses_websocket_cyber_terminal_should_change_the_immediate_next_request() {
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -918,7 +948,7 @@ async fn responses_websocket_history_retry_should_not_fallback_when_owner_accoun
assert_eq!(second.last().unwrap()["type"], "error");
assert_eq!(
second.last().unwrap()["error"]["code"],
"previous_response_unavailable"
"previous_response_not_found"
);
assert!(
fallback.is_none(),
@@ -14,7 +14,7 @@ async fn desktop_release_update_task_should_start_background_checker() {
}
#[tokio::test]
async fn desktop_release_update_task_should_record_initial_appcast_observation() {
async fn desktop_release_update_task_should_apply_latest_release_to_wire_profile() {
let server = MockServer::start().await;
mount_appcast(
&server,
@@ -1490,7 +1490,7 @@ async fn responses_explicit_previous_response_should_fail_without_transparent_re
let captured = upstream.await.unwrap();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "previous_response_unavailable");
assert_eq!(body["error"]["code"], "previous_response_not_found");
assert!(
body["error"]["message"]
.as_str()
@@ -1714,7 +1714,7 @@ async fn responses_explicit_invalid_reasoning_replay_should_not_retry_without_fu
let captured = upstream.await.unwrap();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "previous_response_unavailable");
assert_eq!(body["error"]["code"], "previous_response_not_found");
assert_eq!(captured.payload["previous_response_id"], "resp_stale");
assert!(!captured.retry_attempted);
}
@@ -1777,7 +1777,7 @@ async fn responses_stream_structural_events_should_not_commit_before_history_fai
let captured = upstream.await.unwrap();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("previous_response_unavailable"));
assert!(body.contains("previous_response_not_found"));
assert!(!body.contains("response.created"));
assert_eq!(captured.payload["previous_response_id"], "resp_stale");
assert!(!captured.retry_attempted);
@@ -1826,7 +1826,7 @@ async fn responses_http_sse_structural_events_should_not_commit_before_history_f
let body = response_text(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("previous_response_unavailable"));
assert!(body.contains("previous_response_not_found"));
assert!(!body.contains("response.created"));
}
+9 -3
View File
@@ -5,7 +5,7 @@ use codex_proxy_rs::infra::database::migrate;
use crate::support::storage::init_test_db;
#[tokio::test]
async fn postgres_schema_is_terminal_v1_without_unused_storage() {
async fn postgres_schema_applies_versioned_migrations_without_unused_storage() {
let (pool, _guard) = init_test_db("schema-terminal-v1").await;
let tables = sqlx::query_scalar::<_, String>(
"select table_name
@@ -41,9 +41,11 @@ async fn postgres_schema_is_terminal_v1_without_unused_storage() {
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].0, 1);
assert_eq!(versions[0].1, "initial");
assert_eq!(versions[1].0, 2);
assert_eq!(versions[1].1, "cache_write_tokens");
assert!(versions.iter().all(|migration| migration.2.len() == 64));
}
@@ -69,7 +71,7 @@ async fn postgres_migrate_should_skip_existing_migration_table() {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(migration_count, 1);
assert_eq!(migration_count, 2);
}
#[tokio::test]
@@ -110,6 +112,7 @@ async fn postgres_schema_separates_success_and_error_facts() {
"input_tokens",
"output_tokens",
"cached_tokens",
"cache_write_tokens",
"reasoning_tokens",
] {
assert!(usage_columns.contains(column), "missing {column}");
@@ -117,6 +120,9 @@ async fn postgres_schema_separates_success_and_error_facts() {
assert!(!usage_columns.contains("level"));
assert!(!usage_columns.contains("failure_class"));
let bucket_columns = columns(&pool, "request_time_buckets").await;
assert!(bucket_columns.contains("cache_write_tokens"));
let error_columns = columns(&pool, "ops_error_logs").await;
for column in [
"client_api_key_id",
+4 -2
View File
@@ -20,6 +20,7 @@ async fn rebuild_buckets_should_replace_only_fully_reconstructible_range() {
success.input_tokens = Some(12);
success.output_tokens = Some(5);
success.cached_tokens = Some(3);
success.cache_write_tokens = Some(2);
PgUsageRecordStore::new(pool.clone())
.append(&success)
.await
@@ -60,15 +61,16 @@ async fn rebuild_buckets_should_replace_only_fully_reconstructible_range() {
assert_eq!(report.deleted_rows, 1);
assert_eq!(report.rebuilt_rows, 1);
let rebuilt: (i64, i64, i64, i64, i64, i64, i64, Option<i64>) = sqlx::query_as(
let rebuilt: (i64, i64, i64, i64, i64, i64, i64, i64, Option<i64>) = sqlx::query_as(
"select success_count, error_count, input_tokens, output_tokens, cached_tokens,
cache_write_tokens,
first_token_latency_count, latency_count, min_latency_ms
from request_time_buckets where account_id = 'acct-1'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rebuilt, (1, 1, 12, 5, 3, 1, 1, Some(0)));
assert_eq!(rebuilt, (1, 1, 12, 5, 3, 2, 1, 1, Some(0)));
let preserved: (i64, i64) = sqlx::query_as(
"select success_count, error_count
+4 -3
View File
@@ -241,11 +241,12 @@ async fn usage_record_and_bucket_are_one_transaction_and_keep_zero_ms_samples()
record.first_token_ms = Some(0);
record.input_tokens = Some(11);
record.output_tokens = Some(7);
record.cache_write_tokens = Some(3);
record.service_tier = Some("priority".to_string());
store.append(&record).await.unwrap();
let bucket: (i64, i64, i64, i64, i64, String) = sqlx::query_as(
"select success_count, input_tokens, output_tokens, latency_count,
let bucket: (i64, i64, i64, i64, i64, i64, String) = sqlx::query_as(
"select success_count, input_tokens, output_tokens, cache_write_tokens, latency_count,
first_token_latency_count, service_tier
from request_time_buckets where account_id = $1 and model = $2",
)
@@ -254,7 +255,7 @@ async fn usage_record_and_bucket_are_one_transaction_and_keep_zero_ms_samples()
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(bucket, (1, 11, 7, 1, 1, "priority".to_string()));
assert_eq!(bucket, (1, 11, 7, 3, 1, 1, "priority".to_string()));
sqlx::raw_sql(
"create function reject_test_bucket() returns trigger language plpgsql as $$
@@ -7,7 +7,8 @@ fn extract_usage_should_read_codex_usage_shape() {
"input_tokens": 12,
"output_tokens": 5,
"input_tokens_details": {
"cached_tokens": 3
"cached_tokens": 3,
"cache_write_tokens": 4
}
}
});
@@ -20,6 +21,7 @@ fn extract_usage_should_read_codex_usage_shape() {
input_tokens: 12,
output_tokens: 5,
cached_tokens: 3,
cache_write_tokens: 4,
reasoning_tokens: 0,
image_input_tokens: 0,
image_output_tokens: 0,
@@ -49,6 +51,7 @@ fn extract_usage_should_read_reasoning_and_total_tokens() {
input_tokens: 12,
output_tokens: 8,
cached_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 6,
image_input_tokens: 0,
image_output_tokens: 0,
@@ -83,6 +86,7 @@ fn extract_usage_should_read_image_generation_tokens_separately() {
input_tokens: 12,
output_tokens: 5,
cached_tokens: 3,
cache_write_tokens: 0,
reasoning_tokens: 0,
image_input_tokens: 31,
image_output_tokens: 9,
@@ -110,6 +114,7 @@ fn extract_usage_should_read_openai_usage_shape() {
input_tokens: 8,
output_tokens: 4,
cached_tokens: 2,
cache_write_tokens: 0,
reasoning_tokens: 0,
image_input_tokens: 0,
image_output_tokens: 0,
@@ -132,6 +137,7 @@ fn extract_sse_usage_should_prefer_completed_response_usage() {
input_tokens: 3,
output_tokens: 5,
cached_tokens: 1,
cache_write_tokens: 0,
reasoning_tokens: 0,
image_input_tokens: 0,
image_output_tokens: 0,
@@ -154,6 +160,7 @@ fn extract_sse_usage_should_read_completed_image_generation_tokens() {
input_tokens: 12,
output_tokens: 5,
cached_tokens: 3,
cache_write_tokens: 0,
reasoning_tokens: 0,
image_input_tokens: 31,
image_output_tokens: 9,
@@ -170,6 +170,51 @@ fn build_codex_request_should_pass_through_input_and_preserve_context_fields() {
assert!(!codex.force_http_sse);
}
#[test]
fn build_codex_request_should_preserve_lite_and_memgen_transport_semantics() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [],
"client_metadata": {
"ws_request_header_x_openai_internal_codex_responses_lite": "true"
}
});
let mut headers = HeaderMap::new();
headers.insert("x-openai-memgen-request", "true".parse().unwrap());
let request = build_codex_request(body.as_object().unwrap().clone(), &headers, None);
assert_eq!(request.responses_lite.as_deref(), Some("true"));
assert_eq!(request.memgen_request.as_deref(), Some("true"));
assert_eq!(
request.client_metadata().and_then(
|metadata| metadata.get("ws_request_header_x_openai_internal_codex_responses_lite")
),
Some(&json!("true"))
);
}
#[test]
fn build_codex_request_should_prefer_http_lite_header_without_rewriting_metadata() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [],
"client_metadata": {
"ws_request_header_x_openai_internal_codex_responses_lite": "metadata-value"
}
});
let mut headers = HeaderMap::new();
headers.insert(
"x-openai-internal-codex-responses-lite",
"header-value".parse().unwrap(),
);
let request = build_codex_request(body.as_object().unwrap().clone(), &headers, None);
assert_eq!(request.responses_lite.as_deref(), Some("header-value"));
assert_eq!(request.body(), body.as_object().unwrap());
}
#[test]
fn build_codex_request_should_pass_through_array_input_items_verbatim() {
let input = json!([
@@ -76,6 +76,50 @@ fn response_event_signals_should_count_reasoning_as_semantic_output() {
assert!(!signals.text_output);
}
#[test]
fn response_event_signals_should_count_refusal_as_visible_text() {
let signals = response_event_signals(
Some("response.refusal.delta"),
&json!({"delta": "I cannot help with that"}),
);
assert!(signals.semantic_output);
assert!(signals.text_output);
assert!(!signals.reasoning_output);
}
#[test]
fn response_event_signals_should_count_future_nonempty_delta_without_event_table_entry() {
let signals = response_event_signals(
Some("response.future_tool_payload.delta"),
&json!({"delta": {"chunk": 1}}),
);
assert!(signals.semantic_output);
assert!(!signals.text_output);
assert!(!signals.reasoning_output);
}
#[test]
fn response_event_signals_should_count_completed_hosted_tool_item_without_known_fields() {
let signals = response_event_signals(
Some("response.output_item.done"),
&json!({"item": {"type": "mcp_call", "status": "completed"}}),
);
assert!(signals.semantic_output);
}
#[test]
fn response_event_signals_should_count_hosted_tool_execution_phase() {
let signals = response_event_signals(
Some("response.code_interpreter_call.in_progress"),
&json!({"type": "response.code_interpreter_call.in_progress"}),
);
assert!(signals.semantic_output);
}
#[test]
fn first_token_detection_should_ignore_metadata_and_rate_limit_events() {
let body = include_bytes!(
@@ -285,6 +285,8 @@ async fn codex_backend_client_websocket_should_forward_headers_and_preserve_payl
Vec::new(),
);
request.use_websocket = true;
request.responses_lite = Some("true".to_string());
request.memgen_request = Some("true".to_string());
request.set_prompt_cache_key(Some("client-thread".to_string()));
request.set_client_metadata(Some(json!({
"safe": "yes",
@@ -351,7 +353,8 @@ async fn codex_backend_client_websocket_should_forward_headers_and_preserve_payl
json!({
"safe": "yes",
"x-openai-subagent": "review",
"ignored_non_string": 42
"ignored_non_string": 42,
"ws_request_header_x_openai_internal_codex_responses_lite": "true"
})
);
@@ -402,6 +405,16 @@ async fn codex_backend_client_websocket_should_forward_headers_and_preserve_payl
.iter()
.any(|(name, value)| name == "x-openai-subagent" && value == "review")
);
assert!(
headers
.iter()
.all(|(name, _)| name != "x-openai-internal-codex-responses-lite")
);
assert!(
headers
.iter()
.any(|(name, value)| { name == "x-openai-memgen-request" && value == "true" })
);
assert!(
headers
.iter()
@@ -430,6 +443,14 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
.and(wiremock::matchers::header("originator", "Codex Desktop"))
.and(wiremock::matchers::header("x-client-request-id", "req_1"))
.and(wiremock::matchers::header("x-codex-turn-state", "turn_1"))
.and(wiremock::matchers::header(
"x-openai-internal-codex-responses-lite",
"true",
))
.and(wiremock::matchers::header(
"x-openai-memgen-request",
"true",
))
.and(wiremock::matchers::header("cookie", "cf_clearance=old"))
.and(wiremock::matchers::body_json(json!({
"model": "gpt-5.5",
@@ -460,6 +481,8 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
Vec::new(),
);
request.force_http_sse = true;
request.responses_lite = Some("true".to_string());
request.memgen_request = Some("true".to_string());
let response = client
.create_response(
@@ -493,6 +516,7 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
input_tokens: 2,
output_tokens: 3,
cached_tokens: 1,
cache_write_tokens: 0,
reasoning_tokens: 0,
image_input_tokens: 0,
image_output_tokens: 0,
@@ -579,6 +603,123 @@ async fn codex_backend_client_should_send_latest_wire_profile_user_agent() {
assert_eq!(user_agents, vec![expected_user_agent]);
}
#[tokio::test]
async fn codex_backend_client_websocket_should_preserve_exact_chain_then_rotate_profile() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
let mut first_user_agent = String::new();
let mut first = accept_codex_test_websocket_with(first_stream, |request, _response| {
first_user_agent = request
.headers()
.get("user-agent")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
})
.await;
let _ = first.next().await.unwrap().unwrap();
first
.send(Message::Text(
completed_websocket_response("resp_profile_first", 1, 1).into(),
))
.await
.unwrap();
let _ = timeout(Duration::from_secs(2), first.next())
.await
.expect("connection-local continuation should reuse the original websocket")
.unwrap()
.unwrap();
first
.send(Message::Text(
completed_websocket_response("resp_profile_continued", 1, 1).into(),
))
.await
.unwrap();
let (second_stream, _) = timeout(Duration::from_secs(2), listener.accept())
.await
.expect("updated profile should open a new websocket")
.unwrap();
let mut second_user_agent = String::new();
let mut second = accept_codex_test_websocket_with(second_stream, |request, _response| {
second_user_agent = request
.headers()
.get("user-agent")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
})
.await;
let _ = second.next().await.unwrap().unwrap();
second
.send(Message::Text(
completed_websocket_response("resp_profile_second", 1, 1).into(),
))
.await
.unwrap();
(first_user_agent, second_user_agent)
});
let profile = crate::support::wire_profile::test_wire_profile();
let pool = Arc::new(
codex_proxy_rs::upstream::openai::transport::websocket_pool::CodexWebSocketPool::new(
8,
Duration::from_mins(1),
),
);
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
profile.clone(),
)
.with_websocket_pool(pool);
let mut request =
codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"",
Vec::new(),
);
request.use_websocket = true;
request.local_conversation_id = Some("profile-rotation".to_string());
client
.create_response(
&request,
request_context("req_profile_first", Some("acct-profile")),
)
.await
.unwrap();
profile.update_desktop_release("26.900.1", "7001");
let mut continuation = request.clone();
continuation.set_previous_response_id(Some("resp_profile_first".to_string()));
continuation.previous_response_scope = Some(
codex_proxy_rs::upstream::openai::protocol::responses::PreviousResponseScope::ConnectionLocal,
);
client
.create_response(
&continuation,
request_context("req_profile_continued", Some("acct-profile")),
)
.await
.unwrap();
client
.create_response(
&request,
request_context("req_profile_second", Some("acct-profile")),
)
.await
.unwrap();
let (first_user_agent, second_user_agent) = server.await.unwrap();
assert!(first_user_agent.contains("26.707.72221"));
assert!(second_user_agent.contains("26.900.1"));
assert_ne!(first_user_agent, second_user_agent);
}
#[tokio::test]
async fn codex_backend_client_usage_should_use_wham_usage_headers() {
let server = wiremock::MockServer::start().await;
@@ -1039,7 +1039,7 @@ async fn codex_backend_client_should_treat_active_business_frames_as_ping_livene
let _request = websocket.next().await.unwrap().unwrap();
for index in 0..8 {
tokio::time::sleep(Duration::from_millis(20)).await;
tokio::time::sleep(Duration::from_secs(1)).await;
websocket
.send(Message::Text(
json!({
@@ -1061,8 +1061,8 @@ async fn codex_backend_client_should_treat_active_business_frames_as_ping_livene
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
ping_interval: Some(Duration::from_millis(10)),
ping_timeout: Duration::from_millis(60),
ping_interval: Some(Duration::from_millis(100)),
ping_timeout: Duration::from_secs(5),
liveness_timeout: None,
..websocket_pool_config_for_tests(None, None, None)
}));
+2 -2
View File
@@ -131,8 +131,8 @@ upstream/openai/
- `protocol` 定义 Responses body、canonical SSE/WS 事实和序列化,不包含账号策略。
- `failure.rs` 将各 transport 错误规范化为稳定的 `UpstreamFailureFacts`,不解释账号状态。
- `transport` 拥有 HTTP/SSE、WebSocket、TLS、header、连接池、熔断和 transport metrics。
- `profile.rs` 定义配置驱动、启动后不可变的 Codex Desktop 上游请求画像。
- `desktop_release.rs` 只观测官方 appcast;发布变化不会自动改写生效请求画像。
- `profile.rs` 定义配置驱动的 Codex Desktop 上游请求画像;请求在发送前读取原子快照。
- `desktop_release.rs` 定期读取官方 appcast,并自动把合法的 Desktop 版本与构建号应用到运行时画像。新 HTTP 请求立即使用新画像;WebSocket 连接池按实际 opening 画像分代,不复用旧 UA 连接,也不改写在途请求。
- `token_client.rs` 只实现 OAuth token 刷新协议。
### fleet
@@ -20,6 +20,8 @@ const amountItems = computed(() => {
{ label: '输入单价', value: value.inputPriceDisplay, accent: true },
{ label: '输出单价', value: value.outputPriceDisplay, accent: true },
{ label: '缓存读取费用', value: value.cacheReadAmountDisplay, accent: false },
{ label: '缓存写入费用', value: value.cacheWriteAmountDisplay, accent: false },
{ label: '缓存写入单价', value: value.cacheWritePriceDisplay, accent: true },
]
})
const billingItems = computed(() => {
@@ -103,10 +103,12 @@ const billingItems = computed(() => {
{ label: '输入', value: value.inputAmountDisplay, mono: true },
{ label: '输出', value: value.outputAmountDisplay, mono: true },
{ label: '缓存读取', value: value.cacheReadAmountDisplay, mono: true },
{ label: '缓存写入', value: value.cacheWriteAmountDisplay, mono: true },
{ label: '标准费用', value: value.standardAmountDisplay, mono: true },
{ label: '输入单价', value: value.inputPriceDisplay, mono: true },
{ label: '输出单价', value: value.outputPriceDisplay, mono: true },
{ label: '缓存单价', value: value.cacheReadPriceDisplay, mono: true },
{ label: '缓存写入单价', value: value.cacheWritePriceDisplay, mono: true },
{ label: '服务层级', value: value.serviceTierDisplay },
{ label: '倍率', value: value.multiplierDisplay, mono: true },
]
@@ -131,6 +133,12 @@ const tokenChartItems = computed(() => [
display: tokenDetails.value.cachedTokensDisplay,
color: themeColor('--cp-warning', '#D97706'),
},
{
label: '缓存写入',
value: Number(tokenDetails.value.cacheWriteTokens || 0),
display: tokenDetails.value.cacheWriteTokensDisplay,
color: themeColor('--cp-danger', '#DC2626'),
},
{
label: '推理',
value: Number(tokenDetails.value.reasoningTokens || 0),
@@ -14,6 +14,7 @@ const tokenItems = computed(() => [
{ label: '输入 Token', value: tokenDetails.value.inputTokensDisplay },
{ label: '输出 Token', value: tokenDetails.value.outputTokensDisplay },
{ label: '缓存读取 Token', value: tokenDetails.value.cachedTokensDisplay },
{ label: '缓存写入 Token', value: tokenDetails.value.cacheWriteTokensDisplay },
{ label: '推理 Token', value: tokenDetails.value.reasoningTokensDisplay },
])
</script>
+3
View File
@@ -277,6 +277,7 @@ export function usageTokenDetails(record: any) {
const inputTokens = numberValue(usage.inputTokens)
const outputTokens = numberValue(usage.outputTokens)
const cachedTokens = numberValue(usage.cachedTokens)
const cacheWriteTokens = numberValue(usage.cacheWriteTokens)
const reasoningTokens = numberValue(usage.reasoningTokens)
const totalTokens = numberValue(usage.totalTokens) || inputTokens + outputTokens
@@ -284,11 +285,13 @@ export function usageTokenDetails(record: any) {
inputTokens,
outputTokens,
cachedTokens,
cacheWriteTokens,
reasoningTokens,
totalTokens,
inputTokensDisplay: formatTokenCount(inputTokens),
outputTokensDisplay: formatTokenCount(outputTokens),
cachedTokensDisplay: formatCompactTokenCount(cachedTokens),
cacheWriteTokensDisplay: formatCompactTokenCount(cacheWriteTokens),
reasoningTokensDisplay: formatTokenCount(reasoningTokens),
totalTokensDisplay: formatTokenCount(totalTokens),
}