mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
4b384dfe55
- Guard debug body serialization with `log::log_enabled!`; previously
serialized the filtered body to a throwaway String on every forward,
even with debug logging off.
- Skip SSE parse + UTF-8 buffer loop when no usage collector and debug
is off; the per-chunk `serde_json::from_str::<Value>` ran even in
pure passthrough mode.
- Add cheap per-app SSE event pre-filter (string `contains`) so usage
collectors only parse events that could contain usage (e.g. Claude
`message_start` / `message_delta`).
- Skip non-streaming response body JSON parse when usage logging is
disabled.
- Move `ProviderRouter::record_result` off the success response path
via `tokio::spawn` for non-HalfOpen state; that call internally does
`get_proxy_config_for_app` + `update_provider_health`, two SQLite
ops that previously blocked TTFB.
Also: dedupe `usage_logging_enabled` (was duplicated in handlers.rs)
and merge `SseUsageCollector::{new, new_filtered}` into a single
constructor that takes `Option<StreamUsageEventFilter>`.
934 lines
32 KiB
Rust
934 lines
32 KiB
Rust
//! 请求处理器
|
||
//!
|
||
//! 处理各种API端点的HTTP请求
|
||
//!
|
||
//! 重构后的结构:
|
||
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
|
||
//! - 各 handler 只保留独特的业务逻辑
|
||
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
|
||
|
||
use super::{
|
||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||
handler_config::{
|
||
claude_stream_usage_event_filter, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG,
|
||
GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||
},
|
||
handler_context::RequestContext,
|
||
providers::{
|
||
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
|
||
streaming_gemini::create_anthropic_sse_stream_from_gemini,
|
||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||
transform_gemini, transform_responses,
|
||
},
|
||
response_processor::{
|
||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
|
||
usage_logging_enabled, SseUsageCollector,
|
||
},
|
||
server::ProxyState,
|
||
sse::{strip_sse_field, take_sse_block},
|
||
types::*,
|
||
usage::parser::TokenUsage,
|
||
ProxyError,
|
||
};
|
||
use crate::app_config::AppType;
|
||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||
use bytes::Bytes;
|
||
use http_body_util::BodyExt;
|
||
use serde_json::{json, Value};
|
||
|
||
// ============================================================================
|
||
// 健康检查和状态查询(简单端点)
|
||
// ============================================================================
|
||
|
||
/// 健康检查
|
||
pub async fn health_check() -> (StatusCode, Json<Value>) {
|
||
(
|
||
StatusCode::OK,
|
||
Json(json!({
|
||
"status": "healthy",
|
||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||
})),
|
||
)
|
||
}
|
||
|
||
/// 获取服务状态
|
||
pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxyStatus>, ProxyError> {
|
||
let status = state.status.read().await.clone();
|
||
Ok(Json(status))
|
||
}
|
||
|
||
// ============================================================================
|
||
// Claude API 处理器(包含格式转换逻辑)
|
||
// ============================================================================
|
||
|
||
/// 处理 /v1/messages 请求(Claude API)
|
||
///
|
||
/// Claude 处理器包含独特的格式转换逻辑:
|
||
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic ↔ OpenAI 转换)
|
||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||
pub async fn handle_messages(
|
||
State(state): State<ProxyState>,
|
||
request: axum::extract::Request,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await
|
||
}
|
||
|
||
pub async fn handle_claude_desktop_messages(
|
||
State(state): State<ProxyState>,
|
||
request: axum::extract::Request,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
validate_claude_desktop_gateway_auth(&state, request.headers())?;
|
||
handle_messages_for_app(
|
||
state,
|
||
request,
|
||
AppType::ClaudeDesktop,
|
||
"Claude Desktop",
|
||
"claude-desktop",
|
||
Some("/claude-desktop"),
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn handle_claude_desktop_models(
|
||
State(state): State<ProxyState>,
|
||
headers: axum::http::HeaderMap,
|
||
) -> Result<Json<Value>, ProxyError> {
|
||
validate_claude_desktop_gateway_auth(&state, &headers)?;
|
||
let providers = state
|
||
.provider_router
|
||
.select_providers("claude-desktop")
|
||
.await
|
||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||
let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?;
|
||
let response = crate::claude_desktop_config::model_list_response(provider)
|
||
.map_err(|e| ProxyError::ConfigError(e.to_string()))?;
|
||
Ok(Json(response))
|
||
}
|
||
|
||
async fn handle_messages_for_app(
|
||
state: ProxyState,
|
||
request: axum::extract::Request,
|
||
app_type: AppType,
|
||
tag: &'static str,
|
||
app_type_str: &'static str,
|
||
strip_prefix: Option<&'static str>,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let (parts, body) = request.into_parts();
|
||
let uri = parts.uri;
|
||
let headers = parts.headers;
|
||
let extensions = parts.extensions;
|
||
let body_bytes = body
|
||
.collect()
|
||
.await
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||
.to_bytes();
|
||
let body: Value = serde_json::from_slice(&body_bytes)
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||
|
||
let mut ctx =
|
||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||
|
||
let raw_endpoint = uri
|
||
.path_and_query()
|
||
.map(|path_and_query| path_and_query.as_str())
|
||
.unwrap_or(uri.path());
|
||
let endpoint = strip_prefix
|
||
.and_then(|prefix| raw_endpoint.strip_prefix(prefix))
|
||
.unwrap_or(raw_endpoint);
|
||
|
||
let is_stream = body
|
||
.get("stream")
|
||
.and_then(|s| s.as_bool())
|
||
.unwrap_or(false);
|
||
|
||
// 转发请求
|
||
let forwarder = ctx.create_forwarder(&state);
|
||
let result = match forwarder
|
||
.forward_with_retry(
|
||
&app_type,
|
||
endpoint,
|
||
body.clone(),
|
||
headers,
|
||
extensions,
|
||
ctx.get_providers(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(result) => result,
|
||
Err(mut err) => {
|
||
if let Some(provider) = err.provider.take() {
|
||
ctx.provider = provider;
|
||
}
|
||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||
return Err(err.error);
|
||
}
|
||
};
|
||
|
||
ctx.provider = result.provider;
|
||
let api_format = result
|
||
.claude_api_format
|
||
.as_deref()
|
||
.unwrap_or_else(|| get_claude_api_format(&ctx.provider))
|
||
.to_string();
|
||
let response = result.response;
|
||
|
||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||
let adapter = get_adapter(&app_type);
|
||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||
|
||
// Claude 特有:格式转换处理
|
||
if needs_transform {
|
||
return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
|
||
.await;
|
||
}
|
||
|
||
// 通用响应处理(透传模式)
|
||
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||
}
|
||
|
||
fn validate_claude_desktop_gateway_auth(
|
||
state: &ProxyState,
|
||
headers: &axum::http::HeaderMap,
|
||
) -> Result<(), ProxyError> {
|
||
let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref())
|
||
.map_err(|e| ProxyError::AuthError(e.to_string()))?;
|
||
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||
return Err(ProxyError::AuthError(
|
||
"Claude Desktop gateway 缺少 Authorization 头".to_string(),
|
||
));
|
||
};
|
||
let value = value
|
||
.to_str()
|
||
.map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?;
|
||
let token = value
|
||
.strip_prefix("Bearer ")
|
||
.or_else(|| value.strip_prefix("bearer "))
|
||
.unwrap_or("")
|
||
.trim();
|
||
if token != expected {
|
||
return Err(ProxyError::AuthError(
|
||
"Claude Desktop gateway token 无效".to_string(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Claude 格式转换处理(独有逻辑)
|
||
///
|
||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||
async fn handle_claude_transform(
|
||
response: super::hyper_client::ProxyResponse,
|
||
ctx: &RequestContext,
|
||
state: &ProxyState,
|
||
original_body: &Value,
|
||
is_stream: bool,
|
||
api_format: &str,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let status = response.status();
|
||
let is_codex_oauth = ctx
|
||
.provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.provider_type.as_deref())
|
||
== Some("codex_oauth");
|
||
// Codex OAuth 会把 openai_responses 响应强制升级为 SSE,即使客户端发的是 stream:false。
|
||
// should_use_claude_transform_streaming 默认会把这个组合路由到流式转换器——虽然能避免
|
||
// JSON parse 报 422,但会让非流客户端收到 text/event-stream,违反 Anthropic 非流语义。
|
||
// 这里为这个特定组合打开 override:把上游 SSE 聚合成 Anthropic JSON 回给客户端,其它
|
||
// 场景(任意上游 is_sse、非 Codex OAuth 等)仍沿用原有流式兜底。
|
||
let aggregate_codex_oauth_responses_sse =
|
||
!is_stream && is_codex_oauth && api_format == "openai_responses";
|
||
let use_streaming = if aggregate_codex_oauth_responses_sse {
|
||
false
|
||
} else {
|
||
should_use_claude_transform_streaming(
|
||
is_stream,
|
||
response.is_sse(),
|
||
api_format,
|
||
is_codex_oauth,
|
||
)
|
||
};
|
||
let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body);
|
||
let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints);
|
||
|
||
if use_streaming {
|
||
// 根据 api_format 选择流式转换器
|
||
let stream = response.bytes_stream();
|
||
let sse_stream: Box<
|
||
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
|
||
> = if api_format == "openai_responses" {
|
||
Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))
|
||
} else if api_format == "gemini_native" {
|
||
Box::new(Box::pin(create_anthropic_sse_stream_from_gemini(
|
||
stream,
|
||
Some(state.gemini_shadow.clone()),
|
||
Some(ctx.provider.id.clone()),
|
||
Some(ctx.session_id.clone()),
|
||
tool_schema_hints.clone(),
|
||
)))
|
||
} else {
|
||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||
};
|
||
|
||
// 创建使用量收集器;关闭 usage logging 时不要再解析转换后的 SSE。
|
||
let usage_collector = if usage_logging_enabled(state) {
|
||
let state = state.clone();
|
||
let provider_id = ctx.provider.id.clone();
|
||
let model = ctx.request_model.clone();
|
||
let status_code = status.as_u16();
|
||
let start_time = ctx.start_time;
|
||
let session_id = ctx.session_id.clone();
|
||
|
||
Some(SseUsageCollector::new(
|
||
start_time,
|
||
Some(claude_stream_usage_event_filter),
|
||
move |events, first_token_ms| {
|
||
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
|
||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||
let state = state.clone();
|
||
let provider_id = provider_id.clone();
|
||
let model = model.clone();
|
||
let session_id = session_id.clone();
|
||
|
||
tokio::spawn(async move {
|
||
log_usage(
|
||
&state,
|
||
&provider_id,
|
||
"claude",
|
||
&model,
|
||
&model,
|
||
usage,
|
||
latency_ms,
|
||
first_token_ms,
|
||
true,
|
||
status_code,
|
||
Some(session_id),
|
||
)
|
||
.await;
|
||
});
|
||
} else {
|
||
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
|
||
}
|
||
},
|
||
))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// 获取流式超时配置
|
||
let timeout_config = ctx.streaming_timeout_config();
|
||
|
||
let logged_stream = create_logged_passthrough_stream(
|
||
sse_stream,
|
||
"Claude/OpenRouter",
|
||
usage_collector,
|
||
timeout_config,
|
||
);
|
||
|
||
let mut headers = axum::http::HeaderMap::new();
|
||
headers.insert(
|
||
"Content-Type",
|
||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||
);
|
||
headers.insert(
|
||
"Cache-Control",
|
||
axum::http::HeaderValue::from_static("no-cache"),
|
||
);
|
||
|
||
let body = axum::body::Body::from_stream(logged_stream);
|
||
return Ok((headers, body).into_response());
|
||
}
|
||
|
||
// 非流式响应转换 (OpenAI/Responses → Anthropic)
|
||
let body_timeout =
|
||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||
} else {
|
||
std::time::Duration::ZERO
|
||
};
|
||
let (mut response_headers, _status, body_bytes) =
|
||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||
|
||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||
|
||
let upstream_response: Value = if aggregate_codex_oauth_responses_sse {
|
||
responses_sse_to_response_value(&body_str)?
|
||
} else {
|
||
serde_json::from_slice(&body_bytes).map_err(|e| {
|
||
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
|
||
})?
|
||
};
|
||
|
||
// 根据 api_format 选择非流式转换器
|
||
let anthropic_response = if api_format == "openai_responses" {
|
||
transform_responses::responses_to_anthropic(upstream_response)
|
||
} else if api_format == "gemini_native" {
|
||
transform_gemini::gemini_to_anthropic_with_shadow_and_hints(
|
||
upstream_response,
|
||
Some(state.gemini_shadow.as_ref()),
|
||
Some(&ctx.provider.id),
|
||
Some(&ctx.session_id),
|
||
tool_schema_hints.as_ref(),
|
||
)
|
||
} else {
|
||
transform::openai_to_anthropic(upstream_response)
|
||
}
|
||
.map_err(|e| {
|
||
log::error!("[Claude] 转换响应失败: {e}");
|
||
e
|
||
})?;
|
||
|
||
// 记录使用量
|
||
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
|
||
let model = anthropic_response
|
||
.get("model")
|
||
.and_then(|m| m.as_str())
|
||
.unwrap_or("unknown");
|
||
let latency_ms = ctx.latency_ms();
|
||
|
||
let request_model = ctx.request_model.clone();
|
||
tokio::spawn({
|
||
let state = state.clone();
|
||
let provider_id = ctx.provider.id.clone();
|
||
let model = model.to_string();
|
||
let session_id = ctx.session_id.clone();
|
||
async move {
|
||
log_usage(
|
||
&state,
|
||
&provider_id,
|
||
"claude",
|
||
&model,
|
||
&request_model,
|
||
usage,
|
||
latency_ms,
|
||
None,
|
||
false,
|
||
status.as_u16(),
|
||
Some(session_id),
|
||
)
|
||
.await;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 构建响应
|
||
let mut builder = axum::response::Response::builder().status(status);
|
||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||
|
||
for (key, value) in response_headers.iter() {
|
||
builder = builder.header(key, value);
|
||
}
|
||
|
||
builder = builder.header("content-type", "application/json");
|
||
|
||
let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| {
|
||
log::error!("[Claude] 序列化响应失败: {e}");
|
||
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
|
||
})?;
|
||
|
||
let body = axum::body::Body::from(response_body);
|
||
builder.body(body).map_err(|e| {
|
||
log::error!("[Claude] 构建响应失败: {e}");
|
||
ProxyError::Internal(format!("Failed to build response: {e}"))
|
||
})
|
||
}
|
||
|
||
fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
|
||
match uri.query() {
|
||
Some(query) => format!("{endpoint}?{query}"),
|
||
None => endpoint.to_string(),
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Codex API 处理器
|
||
// ============================================================================
|
||
|
||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||
pub async fn handle_chat_completions(
|
||
State(state): State<ProxyState>,
|
||
request: axum::extract::Request,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let (parts, req_body) = request.into_parts();
|
||
let uri = parts.uri;
|
||
let headers = parts.headers;
|
||
let extensions = parts.extensions;
|
||
let body_bytes = req_body
|
||
.collect()
|
||
.await
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||
.to_bytes();
|
||
let body: Value = serde_json::from_slice(&body_bytes)
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||
|
||
let mut ctx =
|
||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||
let endpoint = endpoint_with_query(&uri, "/chat/completions");
|
||
|
||
let is_stream = body
|
||
.get("stream")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false);
|
||
|
||
let forwarder = ctx.create_forwarder(&state);
|
||
let result = match forwarder
|
||
.forward_with_retry(
|
||
&AppType::Codex,
|
||
&endpoint,
|
||
body,
|
||
headers,
|
||
extensions,
|
||
ctx.get_providers(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(result) => result,
|
||
Err(mut err) => {
|
||
if let Some(provider) = err.provider.take() {
|
||
ctx.provider = provider;
|
||
}
|
||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||
return Err(err.error);
|
||
}
|
||
};
|
||
|
||
ctx.provider = result.provider;
|
||
let response = result.response;
|
||
|
||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||
}
|
||
|
||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||
pub async fn handle_responses(
|
||
State(state): State<ProxyState>,
|
||
request: axum::extract::Request,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let (parts, req_body) = request.into_parts();
|
||
let uri = parts.uri;
|
||
let headers = parts.headers;
|
||
let extensions = parts.extensions;
|
||
let body_bytes = req_body
|
||
.collect()
|
||
.await
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||
.to_bytes();
|
||
let body: Value = serde_json::from_slice(&body_bytes)
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||
|
||
let mut ctx =
|
||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||
|
||
let is_stream = body
|
||
.get("stream")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false);
|
||
|
||
let forwarder = ctx.create_forwarder(&state);
|
||
let result = match forwarder
|
||
.forward_with_retry(
|
||
&AppType::Codex,
|
||
&endpoint,
|
||
body,
|
||
headers,
|
||
extensions,
|
||
ctx.get_providers(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(result) => result,
|
||
Err(mut err) => {
|
||
if let Some(provider) = err.provider.take() {
|
||
ctx.provider = provider;
|
||
}
|
||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||
return Err(err.error);
|
||
}
|
||
};
|
||
|
||
ctx.provider = result.provider;
|
||
let response = result.response;
|
||
|
||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||
}
|
||
|
||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||
pub async fn handle_responses_compact(
|
||
State(state): State<ProxyState>,
|
||
request: axum::extract::Request,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let (parts, req_body) = request.into_parts();
|
||
let uri = parts.uri;
|
||
let headers = parts.headers;
|
||
let extensions = parts.extensions;
|
||
let body_bytes = req_body
|
||
.collect()
|
||
.await
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||
.to_bytes();
|
||
let body: Value = serde_json::from_slice(&body_bytes)
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||
|
||
let mut ctx =
|
||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||
|
||
let is_stream = body
|
||
.get("stream")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false);
|
||
|
||
let forwarder = ctx.create_forwarder(&state);
|
||
let result = match forwarder
|
||
.forward_with_retry(
|
||
&AppType::Codex,
|
||
&endpoint,
|
||
body,
|
||
headers,
|
||
extensions,
|
||
ctx.get_providers(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(result) => result,
|
||
Err(mut err) => {
|
||
if let Some(provider) = err.provider.take() {
|
||
ctx.provider = provider;
|
||
}
|
||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||
return Err(err.error);
|
||
}
|
||
};
|
||
|
||
ctx.provider = result.provider;
|
||
let response = result.response;
|
||
|
||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||
}
|
||
|
||
// ============================================================================
|
||
// Gemini API 处理器
|
||
// ============================================================================
|
||
|
||
/// 处理 Gemini API 请求(透传,包括查询参数)
|
||
pub async fn handle_gemini(
|
||
State(state): State<ProxyState>,
|
||
uri: axum::http::Uri,
|
||
request: axum::extract::Request,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let (parts, req_body) = request.into_parts();
|
||
let headers = parts.headers;
|
||
let extensions = parts.extensions;
|
||
let body_bytes = req_body
|
||
.collect()
|
||
.await
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||
.to_bytes();
|
||
let body: Value = serde_json::from_slice(&body_bytes)
|
||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||
|
||
// Gemini 的模型名称在 URI 中
|
||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||
.await?
|
||
.with_model_from_uri(&uri);
|
||
|
||
// 提取完整的路径和查询参数
|
||
let endpoint = uri
|
||
.path_and_query()
|
||
.map(|pq| pq.as_str())
|
||
.unwrap_or(uri.path());
|
||
|
||
let is_stream = body
|
||
.get("stream")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false);
|
||
|
||
let forwarder = ctx.create_forwarder(&state);
|
||
let result = match forwarder
|
||
.forward_with_retry(
|
||
&AppType::Gemini,
|
||
endpoint,
|
||
body,
|
||
headers,
|
||
extensions,
|
||
ctx.get_providers(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(result) => result,
|
||
Err(mut err) => {
|
||
if let Some(provider) = err.provider.take() {
|
||
ctx.provider = provider;
|
||
}
|
||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||
return Err(err.error);
|
||
}
|
||
};
|
||
|
||
ctx.provider = result.provider;
|
||
let response = result.response;
|
||
|
||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||
}
|
||
|
||
fn should_use_claude_transform_streaming(
|
||
requested_streaming: bool,
|
||
upstream_is_sse: bool,
|
||
api_format: &str,
|
||
is_codex_oauth: bool,
|
||
) -> bool {
|
||
requested_streaming || upstream_is_sse || (is_codex_oauth && api_format == "openai_responses")
|
||
}
|
||
|
||
/// 把 OpenAI Responses SSE 流聚合成一个完整的 Responses JSON 对象,供下游转成 Anthropic
|
||
/// 非流响应。仅在 Codex OAuth 把 `stream:false` 强制升级为 SSE 的场景下调用。
|
||
///
|
||
/// 复用 `proxy::sse` 的 `take_sse_block`/`strip_sse_field`:`take_sse_block` 同时支持
|
||
/// `\n\n` 与 `\r\n\r\n` 两种分隔符,`strip_sse_field` 兼容带/不带空格的字段写法。
|
||
fn responses_sse_to_response_value(body: &str) -> Result<Value, ProxyError> {
|
||
let mut buffer = body.to_string();
|
||
let mut completed_response: Option<Value> = None;
|
||
let mut output_items = Vec::new();
|
||
|
||
while let Some(block) = take_sse_block(&mut buffer) {
|
||
let mut event_name = "";
|
||
let mut data_lines: Vec<&str> = Vec::new();
|
||
|
||
for line in block.lines() {
|
||
if let Some(evt) = strip_sse_field(line, "event") {
|
||
event_name = evt.trim();
|
||
} else if let Some(d) = strip_sse_field(line, "data") {
|
||
data_lines.push(d);
|
||
}
|
||
}
|
||
|
||
if data_lines.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let data_str = data_lines.join("\n");
|
||
if data_str.trim() == "[DONE]" {
|
||
continue;
|
||
}
|
||
|
||
let data: Value = serde_json::from_str(&data_str).map_err(|e| {
|
||
ProxyError::TransformError(format!("Failed to parse upstream SSE event: {e}"))
|
||
})?;
|
||
|
||
match event_name {
|
||
"response.output_item.done" => {
|
||
if let Some(item) = data.get("item") {
|
||
output_items.push(item.clone());
|
||
}
|
||
}
|
||
"response.completed" => {
|
||
completed_response = Some(data.get("response").cloned().unwrap_or(data));
|
||
}
|
||
"response.failed" => {
|
||
let message = data
|
||
.pointer("/response/error/message")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("response.failed event received");
|
||
return Err(ProxyError::TransformError(message.to_string()));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
let mut response = completed_response.ok_or_else(|| {
|
||
ProxyError::TransformError("No response.completed event in upstream SSE".to_string())
|
||
})?;
|
||
|
||
if !output_items.is_empty() {
|
||
if let Some(obj) = response.as_object_mut() {
|
||
obj.insert("output".to_string(), Value::Array(output_items));
|
||
} else {
|
||
return Err(ProxyError::TransformError(
|
||
"response.completed payload is not an object".to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
Ok(response)
|
||
}
|
||
|
||
// ============================================================================
|
||
// 使用量记录(保留用于 Claude 转换逻辑)
|
||
// ============================================================================
|
||
|
||
fn log_forward_error(
|
||
state: &ProxyState,
|
||
ctx: &RequestContext,
|
||
is_streaming: bool,
|
||
error: &ProxyError,
|
||
) {
|
||
use super::usage::logger::UsageLogger;
|
||
|
||
let logger = UsageLogger::new(&state.db);
|
||
let status_code = map_proxy_error_to_status(error);
|
||
let error_message = get_error_message(error);
|
||
let request_id = uuid::Uuid::new_v4().to_string();
|
||
|
||
if let Err(e) = logger.log_error_with_context(
|
||
request_id,
|
||
ctx.provider.id.clone(),
|
||
ctx.app_type_str.to_string(),
|
||
ctx.request_model.clone(),
|
||
status_code,
|
||
error_message,
|
||
ctx.latency_ms(),
|
||
is_streaming,
|
||
Some(ctx.session_id.clone()),
|
||
None,
|
||
) {
|
||
log::warn!("记录失败请求日志失败: {e}");
|
||
}
|
||
}
|
||
|
||
/// 记录请求使用量
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn log_usage(
|
||
state: &ProxyState,
|
||
provider_id: &str,
|
||
app_type: &str,
|
||
model: &str,
|
||
request_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;
|
||
|
||
if !usage_logging_enabled(state) {
|
||
return;
|
||
}
|
||
|
||
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 == "request" {
|
||
request_model
|
||
} else {
|
||
model
|
||
};
|
||
|
||
let request_id = usage.dedup_request_id();
|
||
|
||
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}");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{responses_sse_to_response_value, should_use_claude_transform_streaming};
|
||
use crate::proxy::ProxyError;
|
||
|
||
#[test]
|
||
fn codex_oauth_responses_force_streaming_even_if_client_sent_false() {
|
||
assert!(should_use_claude_transform_streaming(
|
||
false,
|
||
false,
|
||
"openai_responses",
|
||
true,
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn upstream_sse_response_always_uses_streaming_path() {
|
||
assert!(should_use_claude_transform_streaming(
|
||
false,
|
||
true,
|
||
"openai_chat",
|
||
false,
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn non_streaming_response_stays_non_streaming_for_regular_openai_responses() {
|
||
assert!(!should_use_claude_transform_streaming(
|
||
false,
|
||
false,
|
||
"openai_responses",
|
||
false,
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn responses_sse_to_response_value_collects_output_items() {
|
||
let sse = r#"event: response.output_item.done
|
||
data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}
|
||
|
||
event: response.completed
|
||
data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":10,"output_tokens":2}}}
|
||
|
||
"#;
|
||
|
||
let response = responses_sse_to_response_value(sse).unwrap();
|
||
|
||
assert_eq!(response["id"], "resp_1");
|
||
assert_eq!(response["output"][0]["type"], "message");
|
||
assert_eq!(response["output"][0]["content"][0]["text"], "hello");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_sse_to_response_value_handles_crlf_delimiters() {
|
||
// 真实 HTTP SSE 按规范使用 \r\n\r\n 分隔事件;take_sse_block 必须同时处理两种分隔符,
|
||
// 否则此路径在任何标准上游(含 Codex OAuth HTTPS 后端)下都会 TransformError。
|
||
let sse = "event: response.output_item.done\r\n\
|
||
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}}\r\n\
|
||
\r\n\
|
||
event: response.completed\r\n\
|
||
data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_crlf\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\r\n\
|
||
\r\n";
|
||
|
||
let response = responses_sse_to_response_value(sse).unwrap();
|
||
|
||
assert_eq!(response["id"], "resp_crlf");
|
||
assert_eq!(response["output"][0]["type"], "message");
|
||
assert_eq!(response["output"][0]["content"][0]["text"], "hi");
|
||
}
|
||
|
||
#[test]
|
||
fn responses_sse_to_response_value_returns_err_on_response_failed() {
|
||
let sse = "event: response.failed\n\
|
||
data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream blew up\"}}}\n\n";
|
||
|
||
let err = responses_sse_to_response_value(sse).unwrap_err();
|
||
match err {
|
||
ProxyError::TransformError(msg) => assert!(msg.contains("upstream blew up")),
|
||
other => panic!("expected TransformError, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn responses_sse_to_response_value_errors_when_no_completed_event() {
|
||
let sse = "event: response.output_item.done\n\
|
||
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n\n";
|
||
|
||
assert!(responses_sse_to_response_value(sse).is_err());
|
||
}
|
||
}
|