mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
0dd823ae3a
* fix(proxy): fix Codex 404 errors with custom base_url prefixes - handlers.rs:268: Remove hardcoded /v1 prefix in Codex forwarding - codex.rs:140: Only add /v1 for origin-only base_urls, dedupe /v1/v1 - stream_check.rs:364: Try /responses first, fallback to /v1/responses - provider.rs:427: Don't force /v1 for custom prefix base_urls Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(codex): always add /v1 for custom prefix base_urls Changed logic to always add /v1 prefix unless base_url already ends with /v1. This fixes 504 timeout errors with relay services that expect /v1 in the path. - Most relay services follow OpenAI standard format: /v1/responses - Users can opt-out by adding /v1 to their base_url configuration - Updated test case to reflect new behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(proxy): allow system proxy on localhost with different ports - Only bypass system proxy if it points to CC Switch's own port (15721) - Allow other localhost proxies (e.g., Clash on 7890) to be used - Add INFO level logging for request URLs to aid debugging This fixes connection timeout issues when using local proxy tools. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(codex): don't add /v1 for custom prefix base_urls Reverted logic to not add /v1 for base_urls with custom prefixes. Many relay services use custom paths without /v1. - Pure origin (e.g., https://api.openai.com) → adds /v1 - With /v1 (e.g., https://api.openai.com/v1) → no change - Custom prefix (e.g., https://example.com/openai) → no /v1 This fixes 404 errors with relay services that don't use /v1 in their paths. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(proxy): use dynamic port for system proxy detection Instead of hardcoding port 15721, now uses the actual configured listen_port from proxy settings. - Added set_proxy_port() to update the port when proxy server starts - Added get_proxy_port() to retrieve current port for detection - Updated server.rs to call set_proxy_port() on startup - Updated tests to reflect new behavior This allows users to change the proxy port in settings without breaking the system proxy detection logic. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(proxy): change default proxy port from 15721 to 5000 Update the default fallback port in get_proxy_port() from 15721 to 5000 to match the project's standard default port configuration. Also updated test cases to use port 5000 consistently. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(proxy): revert default port back to 15721 Revert the default fallback port in get_proxy_port() from 5000 back to 15721 to align with the project's updated default port configuration. Also updated test cases to use port 15721 consistently. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: ozbombor <ozbombor@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
485 lines
15 KiB
Rust
485 lines
15 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_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||
},
|
||
handler_context::RequestContext,
|
||
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
|
||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||
server::ProxyState,
|
||
types::*,
|
||
usage::parser::TokenUsage,
|
||
ProxyError,
|
||
};
|
||
use crate::app_config::AppType;
|
||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||
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>,
|
||
headers: axum::http::HeaderMap,
|
||
Json(body): Json<Value>,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let mut ctx =
|
||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||
|
||
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(
|
||
&AppType::Claude,
|
||
"/v1/messages",
|
||
body.clone(),
|
||
headers,
|
||
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;
|
||
|
||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||
let adapter = get_adapter(&AppType::Claude);
|
||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||
|
||
// Claude 特有:格式转换处理
|
||
if needs_transform {
|
||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||
}
|
||
|
||
// 通用响应处理(透传模式)
|
||
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||
}
|
||
|
||
/// Claude 格式转换处理(独有逻辑)
|
||
///
|
||
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||
async fn handle_claude_transform(
|
||
response: reqwest::Response,
|
||
ctx: &RequestContext,
|
||
state: &ProxyState,
|
||
_original_body: &Value,
|
||
is_stream: bool,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let status = response.status();
|
||
|
||
if is_stream {
|
||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||
let stream = response.bytes_stream();
|
||
let sse_stream = create_anthropic_sse_stream(stream);
|
||
|
||
// 创建使用量收集器
|
||
let usage_collector = {
|
||
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;
|
||
|
||
SseUsageCollector::new(start_time, 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();
|
||
|
||
tokio::spawn(async move {
|
||
log_usage(
|
||
&state,
|
||
&provider_id,
|
||
"claude",
|
||
&model,
|
||
&model,
|
||
usage,
|
||
latency_ms,
|
||
first_token_ms,
|
||
true,
|
||
status_code,
|
||
)
|
||
.await;
|
||
});
|
||
} else {
|
||
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
|
||
}
|
||
})
|
||
};
|
||
|
||
// 获取流式超时配置
|
||
let timeout_config = ctx.streaming_timeout_config();
|
||
|
||
let logged_stream = create_logged_passthrough_stream(
|
||
sse_stream,
|
||
"Claude/OpenRouter",
|
||
Some(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"),
|
||
);
|
||
headers.insert(
|
||
"Connection",
|
||
axum::http::HeaderValue::from_static("keep-alive"),
|
||
);
|
||
|
||
let body = axum::body::Body::from_stream(logged_stream);
|
||
return Ok((headers, body).into_response());
|
||
}
|
||
|
||
// 非流式响应转换 (OpenAI → Anthropic)
|
||
let response_headers = response.headers().clone();
|
||
|
||
let body_bytes = response.bytes().await.map_err(|e| {
|
||
log::error!("[Claude] 读取响应体失败: {e}");
|
||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||
})?;
|
||
|
||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||
|
||
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||
})?;
|
||
|
||
let anthropic_response = transform::openai_to_anthropic(openai_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();
|
||
async move {
|
||
log_usage(
|
||
&state,
|
||
&provider_id,
|
||
"claude",
|
||
&model,
|
||
&request_model,
|
||
usage,
|
||
latency_ms,
|
||
None,
|
||
false,
|
||
status.as_u16(),
|
||
)
|
||
.await;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 构建响应
|
||
let mut builder = axum::response::Response::builder().status(status);
|
||
|
||
for (key, value) in response_headers.iter() {
|
||
if key.as_str().to_lowercase() != "content-length"
|
||
&& key.as_str().to_lowercase() != "transfer-encoding"
|
||
{
|
||
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}"))
|
||
})
|
||
}
|
||
|
||
// ============================================================================
|
||
// Codex API 处理器
|
||
// ============================================================================
|
||
|
||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||
pub async fn handle_chat_completions(
|
||
State(state): State<ProxyState>,
|
||
headers: axum::http::HeaderMap,
|
||
Json(body): Json<Value>,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let mut ctx =
|
||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||
|
||
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,
|
||
"/chat/completions",
|
||
body,
|
||
headers,
|
||
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>,
|
||
headers: axum::http::HeaderMap,
|
||
Json(body): Json<Value>,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
let mut ctx =
|
||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||
|
||
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,
|
||
"/responses",
|
||
body,
|
||
headers,
|
||
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,
|
||
headers: axum::http::HeaderMap,
|
||
Json(body): Json<Value>,
|
||
) -> Result<axum::response::Response, ProxyError> {
|
||
// 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,
|
||
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
|
||
}
|
||
|
||
// ============================================================================
|
||
// 使用量记录(保留用于 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,
|
||
) {
|
||
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 == "request" {
|
||
request_model
|
||
} else {
|
||
model
|
||
};
|
||
|
||
let request_id = uuid::Uuid::new_v4().to_string();
|
||
|
||
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,
|
||
None,
|
||
None, // provider_type
|
||
is_streaming,
|
||
) {
|
||
log::warn!("[USG-001] 记录使用量失败: {e}");
|
||
}
|
||
}
|