Files
CC-Switch/src-tauri/src/proxy/response_processor.rs
T
Chenx Dust 1a0e8c7a44 fix: decompress body before forward and support zstd (#3817)
* fix(proxy): decompress Codex request body before forward, support zstd

Codex Desktop sends zstd-compressed request bodies when authenticated
against the Codex backend, which broke local proxy routing because the
handlers parsed the raw bytes with serde_json directly.

Reworked on top of current main so it preserves the response_processor
behavior that landed after this PR was first opened:

- Extract content-encoding helpers into a shared proxy::content_encoding
  module. decompress_body keeps returning Option<Vec<u8>> so unknown
  encodings stay pass-through with their content-encoding header intact,
  and keeps the deflate zlib-then-raw fallback (RFC 9110).
- Add zstd/zst support (zstd 0.13) and disable reqwest's auto zstd
  decompression via .no_zstd() for parity with gzip/br/deflate.
- Decompress the request body before JSON parsing in the three Codex
  handlers (chat_completions / responses / responses_compact) and strip
  the stale content-encoding / content-length / transfer-encoding headers
  so the forwarder regenerates them.
- Support stacked codings (e.g. "gzip, zstd") by decoding in reverse
  order and merge repeated Content-Encoding headers via get_all.

Fixes #3764
Fixes #3696

Co-authored-by: chenx-dust <16610294+chenx-dust@users.noreply.github.com>

* fix(proxy): decompress upstream error bodies before reading them

The forwarder error branch consumes non-2xx responses via String::from_utf8
directly, bypassing read_decoded_body. reqwest has no auto-decompression
feature enabled, so a compressed error body (gzip/br/deflate/zstd) arrives
as raw bytes, fails from_utf8, and gets dropped, hiding upstream rate-limit
and auth details from the client.

Decode the error body with the shared proxy::content_encoding helper,
mirroring the success path. Falls back to the raw bytes when the encoding is
unsupported or decoding fails.

Co-authored-by: chenx-dust <16610294+chenx-dust@users.noreply.github.com>

---------

Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: chenx-dust <16610294+chenx-dust@users.noreply.github.com>
2026-06-26 08:50:59 +08:00

1198 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 响应处理器模块
//!
//! 统一处理流式和非流式 API 响应
use super::{
content_encoding::{decompress_body, get_content_encoding},
forwarder::ActiveConnectionGuard,
handler_config::{StreamUsageEventFilter, UsageParserConfig},
handler_context::{RequestContext, StreamingTimeoutConfig},
hyper_client::ProxyResponse,
server::ProxyState,
sse::{strip_sse_field, take_sse_block},
usage::parser::TokenUsage,
ProxyError,
};
use crate::database::PRICING_SOURCE_REQUEST;
use axum::http::{header::HeaderMap, HeaderName};
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::Value;
use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tokio::sync::Mutex;
// ============================================================================
// 响应头处理
// ============================================================================
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
];
/// 移除响应侧 hop-by-hop 头,以及 `Connection` 中点名的扩展头。
pub(crate) fn strip_hop_by_hop_response_headers(headers: &mut HeaderMap) {
let connection_listed_headers: Vec<HeaderName> = headers
.get_all(axum::http::header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|name| !name.is_empty())
.filter_map(|name| HeaderName::from_bytes(name.as_bytes()).ok())
.collect();
for name in HOP_BY_HOP_RESPONSE_HEADERS {
headers.remove(*name);
}
for name in connection_listed_headers {
headers.remove(name);
}
}
/// 移除在重建响应体后会失真的实体头。
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
headers.remove(axum::http::header::CONTENT_ENCODING);
headers.remove(axum::http::header::CONTENT_LENGTH);
headers.remove(axum::http::header::TRANSFER_ENCODING);
}
/// 读取响应体并在需要时解压,确保 headers 与返回 body 一致。
///
/// `body_timeout`: 整包超时。当非零时用 `tokio::time::timeout` 包住 `.bytes()` 调用,
/// 防止上游发完响应头后卡住 body 导致请求永远挂住。
/// 传入 `Duration::ZERO` 表示不启用超时(故障转移关闭时)。
pub(crate) async fn read_decoded_body(
response: ProxyResponse,
tag: &str,
body_timeout: Duration,
) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> {
let mut headers = response.headers().clone();
let status = response.status();
let raw_bytes = if body_timeout.is_zero() {
response.bytes().await?
} else {
tokio::time::timeout(body_timeout, response.bytes())
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
body_timeout.as_secs()
))
})??
};
log::debug!(
"[{tag}] 已接收上游响应体: status={}, bytes={}, headers={}",
status.as_u16(),
raw_bytes.len(),
format_headers(&headers)
);
let mut body_bytes = raw_bytes.clone();
let mut decoded = false;
if let Some(encoding) = get_content_encoding(&headers) {
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
match decompress_body(&encoding, &raw_bytes) {
Ok(Some(decompressed)) => {
body_bytes = Bytes::from(decompressed);
decoded = true;
}
// 不支持的编码:原样透传且保留 content-encoding 头,
// 让下游诊断/客户端知道这仍是压缩字节
Ok(None) => {}
Err(e) => {
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
}
}
}
if decoded {
strip_entity_headers_for_rebuilt_body(&mut headers);
}
Ok((headers, status, body_bytes))
}
// ============================================================================
// 公共接口
// ============================================================================
/// 检测响应是否为 SSE 流式响应
#[inline]
pub fn is_sse_response(response: &ProxyResponse) -> bool {
response.is_sse()
}
/// 处理流式响应
pub async fn handle_streaming(
response: ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
connection_guard: Option<ActiveConnectionGuard>,
) -> Response {
let status = response.status();
log::debug!(
"[{}] 已接收上游流式响应: status={}, headers={}",
ctx.tag,
status.as_u16(),
format_headers(response.headers())
);
// 检查流式响应是否被压缩(SSE 通常不压缩,如果压缩则 SSE 解析会失败)
if let Some(encoding) = get_content_encoding(response.headers()) {
log::warn!(
"[{}] 流式响应含 content-encoding={encoding}SSE 解析可能失败。\
上游在 accept-encoding 透传后压缩了 SSE 流。",
ctx.tag
);
}
let mut response_headers = response.headers().clone();
strip_hop_by_hop_response_headers(&mut response_headers);
let mut builder = axum::response::Response::builder().status(status);
// 复制响应头
for (key, value) in &response_headers {
builder = builder.header(key, value);
}
// 创建字节流
let stream = response.bytes_stream();
// 创建使用量收集器;关闭 usage logging 时不要在流式热路径上解析每个 SSE event。
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
// 获取流式超时配置
let timeout_config = ctx.streaming_timeout_config();
// 创建带日志和超时的透传流
let logged_stream = create_logged_passthrough_stream(
stream,
ctx.tag,
usage_collector,
timeout_config,
connection_guard,
);
let body = axum::body::Body::from_stream(logged_stream);
match builder.body(body) {
Ok(resp) => resp,
Err(e) => {
log::error!("[{}] 构建流式响应失败: {e}", ctx.tag);
ProxyError::Internal(format!("Failed to build streaming response: {e}")).into_response()
}
}
}
/// 处理非流式响应
pub async fn handle_non_streaming(
response: ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
// guard 在函数 scope 内持有,整包响应读取完成后随函数返回一并 drop
_connection_guard: Option<ActiveConnectionGuard>,
) -> Result<Response, ProxyError> {
// 整包超时:仅在故障转移开启且配置值非零时生效
let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
} else {
Duration::ZERO
};
let (mut response_headers, status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
strip_hop_by_hop_response_headers(&mut response_headers);
log::debug!(
"[{}] 上游响应体内容: {}",
ctx.tag,
String::from_utf8_lossy(&body_bytes)
);
// 解析并记录使用量。关闭 usage logging 时直接跳过,避免非流式响应整包 JSON parse。
if usage_logging_enabled(state) {
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 归因优先级:usage 解析出的模型 → 响应 model 字段 → 映射后的出站
// 模型(路由接管真值)→ 客户端请求模型。空字符串视为缺失。
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.or_else(|| {
json_value
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string)
})
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
spawn_log_usage(
state,
ctx,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
} else {
let model = json_value
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
&model,
&ctx.request_model,
status.as_u16(),
false,
);
log::debug!(
"[{}] 未能解析 usage 信息,跳过记录",
parser_config.app_type_str
);
}
} else {
log::debug!(
"[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag,
body_bytes.len()
);
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
&ctx.request_model,
status.as_u16(),
false,
);
}
} else {
log::debug!("[{}] usage logging 已关闭,跳过非流式 usage 解析", ctx.tag);
}
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() {
builder = builder.header(key, value);
}
let body = axum::body::Body::from(body_bytes);
builder.body(body).map_err(|e| {
log::error!("[{}] 构建响应失败: {e}", ctx.tag);
ProxyError::Internal(format!("Failed to build response: {e}"))
})
}
/// 通用响应处理入口
///
/// 根据响应类型自动选择流式或非流式处理
pub async fn process_response(
response: ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
connection_guard: Option<ActiveConnectionGuard>,
) -> Result<Response, ProxyError> {
if is_sse_response(&response) {
Ok(handle_streaming(response, ctx, state, parser_config, connection_guard).await)
} else {
handle_non_streaming(response, ctx, state, parser_config, connection_guard).await
}
}
// ============================================================================
// SSE 使用量收集器
// ============================================================================
type UsageCallbackWithTiming = Arc<dyn Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static>;
/// SSE 使用量收集器
#[derive(Clone)]
pub struct SseUsageCollector {
inner: Arc<SseUsageCollectorInner>,
}
struct SseUsageCollectorInner {
events: Mutex<Vec<Value>>,
first_event_time: Mutex<Option<std::time::Instant>>,
first_event_set: AtomicBool,
start_time: std::time::Instant,
on_complete: UsageCallbackWithTiming,
should_collect: Option<StreamUsageEventFilter>,
finished: AtomicBool,
}
impl SseUsageCollector {
/// 创建使用量收集器;`should_collect` 用来在 hot path 跳过与 usage 无关的事件。
pub fn new(
start_time: std::time::Instant,
should_collect: Option<StreamUsageEventFilter>,
callback: impl Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static,
) -> Self {
let on_complete: UsageCallbackWithTiming = Arc::new(callback);
Self {
inner: Arc::new(SseUsageCollectorInner {
events: Mutex::new(Vec::new()),
first_event_time: Mutex::new(None),
first_event_set: AtomicBool::new(false),
start_time,
on_complete,
should_collect,
finished: AtomicBool::new(false),
}),
}
}
pub fn should_collect(&self, data: &str) -> bool {
self.inner
.should_collect
.map(|filter| filter(data))
.unwrap_or(true)
}
/// 标记首个被收集的 SSE 事件时间,沿用 `first_token_ms` 的既有近似语义。
async fn mark_first_collected_event_time(&self) {
if self.inner.first_event_set.load(Ordering::Acquire) {
return;
}
let mut first_time = self.inner.first_event_time.lock().await;
if first_time.is_none() {
*first_time = Some(std::time::Instant::now());
self.inner.first_event_set.store(true, Ordering::Release);
}
}
/// 推送 SSE 事件
pub async fn push(&self, event: Value) {
self.mark_first_collected_event_time().await;
let mut events = self.inner.events.lock().await;
events.push(event);
}
/// 完成收集并触发回调
pub async fn finish(&self) {
if self.inner.finished.swap(true, Ordering::SeqCst) {
return;
}
let events = {
let mut guard = self.inner.events.lock().await;
std::mem::take(&mut *guard)
};
let first_token_ms = {
let first_time = self.inner.first_event_time.lock().await;
first_time.map(|t| (t - self.inner.start_time).as_millis() as u64)
};
(self.inner.on_complete)(events, first_token_ms);
}
}
struct SseUsageFinishGuard {
collector: Option<SseUsageCollector>,
}
impl SseUsageFinishGuard {
fn new(collector: SseUsageCollector) -> Self {
Self {
collector: Some(collector),
}
}
fn disarm(&mut self) {
self.collector = None;
}
}
impl Drop for SseUsageFinishGuard {
fn drop(&mut self) {
if let Some(collector) = self.collector.take() {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
collector.finish().await;
});
} else {
log::warn!("SSE 用量收尾保护触发时 Tokio runtime 不可用,跳过异步 finish");
}
}
}
}
// ============================================================================
// 内部辅助函数
// ============================================================================
/// 创建使用量收集器
fn create_usage_collector(
ctx: &RequestContext,
state: &ProxyState,
status_code: u16,
parser_config: &UsageParserConfig,
) -> Option<SseUsageCollector> {
let logging_enabled = state
.config
.try_read()
.map(|c| c.enable_logging)
.unwrap_or(true);
if !logging_enabled {
return None;
}
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
// 流式事件缺失模型名时的归因兜底:映射后的出站模型(路由接管真值)优先,
// 其次才是客户端请求别名
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
// 用 ctx 的 app_type 而不是 parser_config 的:Claude Desktop 流式透传复用
// CLAUDE_PARSER_CONFIGapp_type_str="claude"),按 parser_config 记账会把
// claude-desktop 的行错记到 claude 名下,导致供应商计价覆盖解析不到。
let app_type_str = ctx.app_type_str;
let tag = ctx.tag;
let start_time = ctx.start_time;
let stream_parser = parser_config.stream_parser;
let model_extractor = parser_config.model_extractor;
let session_id = ctx.session_id.clone();
Some(SseUsageCollector::new(
start_time,
parser_config.stream_event_filter,
move |events, first_token_ms| {
if let Some(usage) = stream_parser(&events) {
let model = model_extractor(&events, &fallback_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
} else {
let model = model_extractor(&events, &fallback_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
TokenUsage::default(),
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
}
},
))
}
/// 异步记录使用量
fn spawn_log_usage(
state: &ProxyState,
ctx: &RequestContext,
usage: TokenUsage,
model: &str,
request_model: &str,
status_code: u16,
is_streaming: bool,
) {
// Check enable_logging before spawning the log task
if let Ok(config) = state.config.try_read() {
if !config.enable_logging {
return;
}
}
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string();
let request_model = request_model.to_string();
// 「按请求计价」模式的锚点:映射后的出站模型,无映射时等于 request_model
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
&app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
is_streaming,
status_code,
Some(session_id),
)
.await;
});
}
pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool {
state
.config
.try_read()
.map(|config| config.enable_logging)
.unwrap_or(true)
}
/// 内部使用量记录函数
///
/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型
/// (路由接管映射后的真值,无映射时等于 request_model)。该模式的语义是
/// 「按代理发出的请求计价、不信任上游回显」,接管场景下发出的请求模型是
/// 映射后的 Y 而非客户端别名 X,按 X 计价会用错定价表行。
#[allow(clippy::too_many_arguments)]
async fn log_usage_internal(
state: &ProxyState,
provider_id: &str,
app_type: &str,
model: &str,
request_model: &str,
outbound_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
is_streaming: bool,
status_code: u16,
session_id: Option<String>,
) {
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
outbound_model
} else {
model
};
let request_id = usage.dedup_request_id();
log::debug!(
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
session_id.as_deref().unwrap_or("none"),
usage.input_tokens,
usage.output_tokens,
usage.cache_read_tokens,
usage.cache_creation_tokens
);
if let Err(e) = logger.log_with_calculation(
request_id,
provider_id.to_string(),
app_type.to_string(),
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
usage,
multiplier,
latency_ms,
first_token_ms,
status_code,
session_id,
None, // provider_type
is_streaming,
) {
log::warn!("[USG-001] 记录使用量失败: {e}");
}
}
/// 创建带日志记录和超时控制的透传流
pub fn create_logged_passthrough_stream(
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
tag: &'static str,
usage_collector: Option<SseUsageCollector>,
timeout_config: StreamingTimeoutConfig,
connection_guard: Option<ActiveConnectionGuard>,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let _conn_guard = connection_guard;
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
let mut collector = usage_collector;
let mut finish_guard = collector.clone().map(SseUsageFinishGuard::new);
let inspect_sse_events =
collector.is_some() || log::log_enabled!(log::Level::Debug);
let mut is_first_chunk = true;
// 超时配置
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
Some(Duration::from_secs(timeout_config.first_byte_timeout))
} else {
None
};
let idle_timeout = if timeout_config.idle_timeout > 0 {
Some(Duration::from_secs(timeout_config.idle_timeout))
} else {
None
};
tokio::pin!(stream);
loop {
// 选择超时时间:首字节超时或静默期超时
let timeout_duration = if is_first_chunk {
first_byte_timeout
} else {
idle_timeout
};
let chunk_result = match timeout_duration {
Some(duration) => {
match tokio::time::timeout(duration, stream.next()).await {
Ok(Some(chunk)) => Some(chunk),
Ok(None) => None, // 流结束
Err(_) => {
// 超时
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
break;
}
}
}
None => stream.next().await, // 无超时限制
};
match chunk_result {
Some(Ok(bytes)) => {
if is_first_chunk {
log::debug!(
"[{tag}] 已接收上游流式首包: bytes={}",
bytes.len()
);
}
is_first_chunk = false;
if inspect_sse_events {
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// 尝试解析并记录完整的 SSE 事件
while let Some(event_text) = take_sse_block(&mut buffer) {
if !event_text.trim().is_empty() {
// 提取 data 部分;只有 usage collector 存在时才解析 JSON。
for line in event_text.lines() {
if let Some(data) = strip_sse_field(line, "data") {
if data.trim() != "[DONE]" {
let collected = match &collector {
Some(c) if c.should_collect(data) => {
match serde_json::from_str::<Value>(data) {
Ok(json_value) => {
c.push(json_value).await;
true
}
Err(_) => false,
}
}
_ => false,
};
if collected {
log::debug!("[{tag}] <<< SSE 事件: {data}");
} else {
log::debug!("[{tag}] <<< SSE 数据: {data}");
}
} else {
log::debug!("[{tag}] <<< SSE: [DONE]");
}
}
}
}
}
}
yield Ok(bytes);
}
Some(Err(e)) => {
log::error!("[{tag}] 流错误: {e}");
yield Err(std::io::Error::other(e.to_string()));
break;
}
None => {
// 流正常结束
break;
}
}
}
if let Some(c) = collector.take() {
c.finish().await;
}
if let Some(guard) = &mut finish_guard {
guard.disarm();
}
}
}
fn format_headers(headers: &HeaderMap) -> String {
headers
.iter()
.map(|(key, value)| {
let value_str = value.to_str().unwrap_or("<non-utf8>");
format!("{key}={value_str}")
})
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::ProviderMeta;
use crate::proxy::failover_switch::FailoverSwitchManager;
use crate::proxy::provider_router::ProviderRouter;
use crate::proxy::providers::{
codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore,
};
use crate::proxy::types::{ProxyConfig, ProxyStatus};
use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
super::strip_sse_field("data: {\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("data:{\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("event: message_start", "event"),
Some("message_start")
);
assert_eq!(
super::strip_sse_field("event:message_start", "event"),
Some("message_start")
);
assert_eq!(super::strip_sse_field("id:1", "data"), None);
}
#[test]
fn test_strip_hop_by_hop_response_headers_removes_standard_headers() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONNECTION,
axum::http::HeaderValue::from_static("keep-alive"),
);
headers.insert(
axum::http::header::HeaderName::from_static("keep-alive"),
axum::http::HeaderValue::from_static("timeout=5"),
);
headers.insert(
axum::http::header::TRANSFER_ENCODING,
axum::http::HeaderValue::from_static("chunked"),
);
headers.insert(
axum::http::header::HeaderName::from_static("proxy-connection"),
axum::http::HeaderValue::from_static("keep-alive"),
);
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
headers.insert(
axum::http::header::CONTENT_LENGTH,
axum::http::HeaderValue::from_static("12"),
);
strip_hop_by_hop_response_headers(&mut headers);
assert!(!headers.contains_key(axum::http::header::CONNECTION));
assert!(!headers.contains_key("keep-alive"));
assert!(!headers.contains_key(axum::http::header::TRANSFER_ENCODING));
assert!(!headers.contains_key("proxy-connection"));
assert_eq!(
headers.get(axum::http::header::CONTENT_TYPE),
Some(&axum::http::HeaderValue::from_static("application/json"))
);
assert_eq!(
headers.get(axum::http::header::CONTENT_LENGTH),
Some(&axum::http::HeaderValue::from_static("12"))
);
}
#[test]
fn test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions() {
let mut headers = HeaderMap::new();
headers.append(
axum::http::header::CONNECTION,
axum::http::HeaderValue::from_static("x-trace-hop, x-debug-hop"),
);
headers.append(
axum::http::header::CONNECTION,
axum::http::HeaderValue::from_static("upgrade"),
);
headers.insert(
axum::http::header::HeaderName::from_static("x-trace-hop"),
axum::http::HeaderValue::from_static("trace"),
);
headers.insert(
axum::http::header::HeaderName::from_static("x-debug-hop"),
axum::http::HeaderValue::from_static("debug"),
);
headers.insert(
axum::http::header::UPGRADE,
axum::http::HeaderValue::from_static("websocket"),
);
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("text/event-stream"),
);
strip_hop_by_hop_response_headers(&mut headers);
assert!(!headers.contains_key(axum::http::header::CONNECTION));
assert!(!headers.contains_key("x-trace-hop"));
assert!(!headers.contains_key("x-debug-hop"));
assert!(!headers.contains_key(axum::http::header::UPGRADE));
assert_eq!(
headers.get(axum::http::header::CONTENT_TYPE),
Some(&axum::http::HeaderValue::from_static("text/event-stream"))
);
}
fn build_state(db: Arc<Database>) -> ProxyState {
ProxyState {
db: db.clone(),
config: Arc::new(RwLock::new(ProxyConfig::default())),
status: Arc::new(RwLock::new(ProxyStatus::default())),
start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(HashMap::new())),
provider_router: Arc::new(ProviderRouter::new(db.clone())),
gemini_shadow: Arc::new(GeminiShadowStore::default()),
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
app_handle: None,
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
}
}
fn seed_pricing(db: &Database) -> Result<(), AppError> {
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["resp-model", "Resp Model", "1.0", "0"],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["req-model", "Req Model", "2.0", "0"],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
fn insert_provider(
db: &Database,
id: &str,
app_type: &str,
meta: ProviderMeta,
) -> Result<(), AppError> {
let meta_json =
serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![id, app_type, "Test Provider", "{}", meta_json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
#[tokio::test]
async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_default_cost_multiplier(app_type, "1.5").await?;
db.set_pricing_model_source(app_type, "response").await?;
seed_pricing(&db)?;
let meta = ProviderMeta {
cost_multiplier: Some("2".to_string()),
pricing_model_source: Some("request".to_string()),
..ProviderMeta::default()
};
insert_provider(&db, "provider-1", app_type, meta)?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
log_usage_internal(
&state,
"provider-1",
app_type,
"resp-model",
"req-model",
"req-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =
conn.query_row(
"SELECT model, request_model, total_cost_usd, cost_multiplier
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-1"],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
assert_eq!(model, "resp-model");
assert_eq!(request_model, "req-model");
assert_eq!(
Decimal::from_str(&cost_multiplier).unwrap(),
Decimal::from_str("2").unwrap()
);
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("4").unwrap()
);
Ok(())
}
#[tokio::test]
async fn test_request_pricing_mode_anchors_to_outbound_model() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_pricing_model_source(app_type, "request").await?;
seed_pricing(&db)?;
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('outbound-model', 'Outbound Model', '4.0', '0')",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
insert_provider(&db, "provider-3", app_type, ProviderMeta::default())?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
// 路由接管场景:客户端请求 req-model($2/M),代理实际发出 outbound-model
// $4/M),上游回显 resp-model。「按请求计价」必须锚定实际发出的模型。
log_usage_internal(
&state,
"provider-3",
app_type,
"resp-model",
"req-model",
"outbound-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (model, request_model, total_cost): (String, String, String) = conn
.query_row(
"SELECT model, request_model, total_cost_usd
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-3"],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
// model / request_model 列不受计价锚点影响
assert_eq!(model, "resp-model");
assert_eq!(request_model, "req-model");
// 按 outbound-model$4/M)计价,而不是 req-model$2/M)或 resp-model$1/M
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("4").unwrap()
);
Ok(())
}
#[tokio::test]
async fn test_claude_desktop_inherits_claude_global_defaults() -> Result<(), AppError> {
use crate::proxy::usage::logger::UsageLogger;
let db = Arc::new(Database::memory()?);
// 全局计费配置只有 claude/codex/gemini 三行;claude-desktop 的
// 全局默认必须继承 claude,而不是静默落回工厂默认(1 / response
db.set_default_cost_multiplier("claude", "1.5").await?;
db.set_pricing_model_source("claude", "request").await?;
let logger = UsageLogger::new(&db);
let (multiplier, source) = logger
.resolve_pricing_config("nonexistent-provider", "claude-desktop")
.await;
assert_eq!(multiplier, Decimal::from_str("1.5").unwrap());
assert_eq!(source, "request");
Ok(())
}
#[tokio::test]
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_default_cost_multiplier(app_type, "1.5").await?;
db.set_pricing_model_source(app_type, "response").await?;
seed_pricing(&db)?;
let meta = ProviderMeta::default();
insert_provider(&db, "provider-2", app_type, meta)?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
log_usage_internal(
&state,
"provider-2",
app_type,
"resp-model",
"req-model",
"req-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (total_cost, cost_multiplier): (String, String) = conn
.query_row(
"SELECT total_cost_usd, cost_multiplier
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-2"],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
assert_eq!(
Decimal::from_str(&cost_multiplier).unwrap(),
Decimal::from_str("1.5").unwrap()
);
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("1.5").unwrap()
);
Ok(())
}
}