Files
CC-Switch/src-tauri/src/proxy/forwarder.rs
T
Jason 74acf1e387 Add Codex Chat-to-Responses bridge
- Add Codex provider API format selection and model mapping for Chat-only upstreams.
- Convert Codex Responses requests to Chat Completions and rebuild Chat responses as Responses output.
- Preserve reasoning_content across non-streaming, streaming, tool calls, and previous_response_id follow-ups.
- Add a bounded Codex Chat history cache for restoring tool calls before tool outputs.
- Cover Chat bridge, compact routing, streaming, and history recovery with focused tests.
2026-05-25 22:20:33 +08:00

3096 lines
122 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.
//! 请求转发器
//!
//! 负责将请求转发到上游Provider,支持故障转移
use super::hyper_client::ProxyResponse;
use super::{
body_filter::filter_private_params_with_whitelist,
error::*,
failover_switch::FailoverSwitchManager,
json_canonical::{canonicalize_value, short_value_hash},
log_codes::fwd as log_fwd,
provider_router::ProviderRouter,
providers::{
codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore, get_adapter,
AuthInfo, AuthStrategy, ProviderAdapter, ProviderType,
},
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
thinking_rectifier::{
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
},
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
ProxyError,
};
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{app_config::AppType, provider::Provider};
use futures::StreamExt;
use http::Extensions;
use serde_json::Value;
use std::sync::Arc;
use tauri::Manager;
use tokio::sync::RwLock;
const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED";
pub struct ForwardResult {
pub response: ProxyResponse,
pub provider: Provider,
pub claude_api_format: Option<String>,
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
}
pub struct ForwardError {
pub error: ProxyError,
pub provider: Option<Provider>,
}
/// 活跃连接 RAII guard
///
/// 构造时把 `ProxyStatus.active_connections` +1Drop 时在 tokio runtime 上调度
/// 一个异步任务执行 -1,从而支持把 guard move 进流式 body futurestream 自然结束
/// 时 guard 与 future 一起 drop)。
///
/// 设计动机:之前在 `forward_with_retry` 出口处同步 -1,但流式响应的 body 实际
/// 在 `create_logged_passthrough_stream` 内还会继续 yield 字节流,导致 UI 的
/// `active_connections` 计数过早归零。RAII guard 让"减量"由 Rust 类型系统驱动,
/// 不需要每条出口路径都手动调用。
pub(crate) struct ActiveConnectionGuard {
status: Arc<RwLock<ProxyStatus>>,
}
impl ActiveConnectionGuard {
pub(crate) async fn acquire(status: Arc<RwLock<ProxyStatus>>) -> Self {
{
let mut s = status.write().await;
s.active_connections = s.active_connections.saturating_add(1);
}
Self { status }
}
}
impl Drop for ActiveConnectionGuard {
fn drop(&mut self) {
// Drop 不能 await:把减量操作调度到 tokio runtime
let status = self.status.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let mut s = status.write().await;
s.active_connections = s.active_connections.saturating_sub(1);
});
}
// 没有 runtime 时静默丢失计数(仅 UI 展示用,可接受最终一致性)
}
}
pub struct RequestForwarder {
/// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
gemini_shadow: Arc<GeminiShadowStore>,
codex_chat_history: Arc<CodexChatHistoryStore>,
/// 故障转移切换管理器
failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
/// 代理会话 ID(用于 Gemini Native shadow replay
session_id: String,
/// Session ID 是否由客户端提供;生成值不能作为上游缓存身份。
session_client_provided: bool,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 优化器配置
optimizer_config: OptimizerConfig,
/// Copilot 优化器配置
copilot_optimizer_config: CopilotOptimizerConfig,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
/// 流式请求响应头等待超时(秒)
streaming_first_byte_timeout: std::time::Duration,
/// 单个客户端请求最多尝试的 provider 数。
///
/// 由 `AppProxyConfig.max_retries` (UI: "请求失败时的重试次数, 0-10") 派生:
/// `max_attempts = max_retries + 1`,所以 max_retries=0 表示仅尝试一家、
/// max_retries=3(默认)表示最多 4 家。loop 同时受 providers.len() 自然限制。
max_attempts: usize,
}
impl RequestForwarder {
#[allow(clippy::too_many_arguments)]
pub fn new(
router: Arc<ProviderRouter>,
non_streaming_timeout: u64,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
gemini_shadow: Arc<GeminiShadowStore>,
codex_chat_history: Arc<CodexChatHistoryStore>,
failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
session_id: String,
session_client_provided: bool,
streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
optimizer_config: OptimizerConfig,
copilot_optimizer_config: CopilotOptimizerConfig,
max_retries: u32,
) -> Self {
// max_retries 是「失败后重试次数」语义,attempt 上限 = retries + 1。
// saturating_add 防止 u32::MAX + 1 溢出。
let max_attempts = (max_retries as usize).saturating_add(1);
Self {
router,
status,
current_providers,
gemini_shadow,
codex_chat_history,
failover_manager,
app_handle,
current_provider_id_at_start,
session_id,
session_client_provided,
rectifier_config,
optimizer_config,
copilot_optimizer_config,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
streaming_first_byte_timeout: std::time::Duration::from_secs(
streaming_first_byte_timeout,
),
max_attempts,
}
}
async fn record_success_result(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
) {
if used_half_open_permit {
if let Err(e) = self
.router
.record_result(provider_id, app_type, true, true, None)
.await
{
log::warn!(
"[{app_type}] 记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
);
}
return;
}
let router = self.router.clone();
let provider_id = provider_id.to_string();
let app_type = app_type.to_string();
tokio::spawn(async move {
if let Err(e) = router
.record_result(&provider_id, &app_type, false, true, None)
.await
{
log::warn!(
"[{app_type}] 异步记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
);
}
});
}
/// 整流(thinking signature 或 budget)重试失败后的统一收尾。
///
/// `None` 表示已记录熔断器、累积 `last_error`/`last_provider`
/// 调用方应 `continue` 让下一家 provider 继续故障转移;
/// `Some(ForwardError)` 表示是客户端错误,没有 provider 能修复,
/// 调用方应直接 `return` 把错误返回给客户端。
#[allow(clippy::too_many_arguments)]
async fn handle_rectifier_retry_failure(
&self,
retry_err: ProxyError,
provider: &Provider,
app_type_str: &str,
used_half_open_permit: bool,
rectifier_label: &str,
last_error: &mut Option<ProxyError>,
last_provider: &mut Option<Provider>,
) -> Option<ForwardError> {
// Provider 错误:本家上游/网络确实出问题,下一家 provider 可能可用 → 继续故障转移。
// 客户端错误:整流后请求仍违法,下一家也修不好 → 直接返回。
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => true,
ProxyError::UpstreamError { status, .. } => *status >= 500,
_ => false,
};
if is_provider_error {
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
{
let mut status = self.status.write().await;
status.last_error = Some(format!(
"Provider {} {rectifier_label}重试失败: {}",
provider.name, retry_err
));
}
*last_error = Some(retry_err);
*last_provider = Some(provider.clone());
return None;
}
self.router
.release_permit_neutral(&provider.id, app_type_str, used_half_open_permit)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
Some(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
})
}
/// 转发请求(带故障转移)
///
/// 这是 thin wrapper:在客户端请求维度记一次 `total_requests` / 调整
/// `active_connections` / 刷新 `last_request_at`,无论 inner 走哪条出口路径,
/// 出口处都会把 `active_connections` 回收。Per-attempt 维度(成功/失败/熔断
/// 等)仍由 inner 内自行更新 `success_requests` / `failed_requests`。
#[allow(clippy::too_many_arguments)]
pub async fn forward_with_retry(
&self,
app_type: &AppType,
method: http::Method,
endpoint: &str,
body: Value,
headers: axum::http::HeaderMap,
extensions: Extensions,
providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> {
let guard = ActiveConnectionGuard::acquire(self.status.clone()).await;
{
let mut s = self.status.write().await;
s.total_requests = s.total_requests.saturating_add(1);
s.last_request_at = Some(chrono::Utc::now().to_rfc3339());
}
let result = self
.forward_with_retry_inner(
app_type, method, endpoint, body, headers, extensions, providers,
)
.await;
// 把 guard 注入到 Ok 结果,让它随响应一起流转到 response_processor
// 在流式 body 的 future 内才真正 drop。
// Err 路径:guard 在函数 scope 内随返回值落地时自动 drop。
result.map(|mut fr| {
fr.connection_guard = Some(guard);
fr
})
}
/// 实际转发逻辑(不包含客户端维度的入口/出口计数)
///
/// # Arguments
/// * `app_type` - 应用类型
/// * `method` - 客户端请求的 HTTP 方法(透传给上游,支持 GET/POST 等)
/// * `endpoint` - API 端点
/// * `body` - 请求体
/// * `headers` - 请求头
/// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers
#[allow(clippy::too_many_arguments)]
async fn forward_with_retry_inner(
&self,
app_type: &AppType,
method: http::Method,
endpoint: &str,
body: Value,
headers: axum::http::HeaderMap,
extensions: Extensions,
providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> {
// 获取适配器
let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str();
if providers.is_empty() {
return Err(ForwardError {
error: ProxyError::NoAvailableProvider,
provider: None,
});
}
let mut last_error = None;
let mut last_provider = None;
let mut attempted_providers = 0usize;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
// 依次尝试每个供应商
for provider in providers.iter() {
// 整流器重试标记:每个 provider 独立持有,避免标记跨 provider 短路故障转移
// —— 首家 provider 整流后被 5xx/timeout 击落时,下家仍能用整流后的请求体走整流流程
let mut rectifier_retried = false;
let mut budget_rectifier_retried = false;
// 上限检查:尊重用户在 AppProxyConfig.max_retries 上配置的「重试次数」。
// 放在熔断器 allow 检查之前,避免在已经超限时还占用 HalfOpen 探测名额。
if attempted_providers >= self.max_attempts {
log::warn!(
"[{app_type_str}] 已达最大尝试次数上限 ({}/{}), 停止故障转移",
attempted_providers,
self.max_attempts
);
break;
}
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
(true, false)
} else {
let permit = self
.router
.allow_provider_request(&provider.id, app_type_str)
.await;
(permit.allowed, permit.used_half_open_permit)
};
if !allowed {
continue;
}
// PRE-SEND 优化器:每个 provider 独立决定是否优化
// clone body 以避免 Bedrock 优化字段泄漏到非 Bedrock providerfailover 场景)
let mut provider_body =
if self.optimizer_config.enabled && is_bedrock_provider(provider) {
let mut b = body.clone();
if self.optimizer_config.thinking_optimizer {
super::thinking_optimizer::optimize(&mut b, &self.optimizer_config);
}
if self.optimizer_config.cache_injection {
super::cache_injector::inject(&mut b, &self.optimizer_config);
}
b
} else {
body.clone()
};
attempted_providers += 1;
// 更新状态中的当前 Provider 信息(per-attempt 维度的标识)
//
// total_requests / last_request_at / active_connections 已由
// forward_with_retry wrapper 在客户端请求维度统一处理,这里只刷
// 新「正在尝试哪个 provider」的展示字段。
{
let mut status = self.status.write().await;
status.current_provider = Some(provider.name.clone());
status.current_provider_id = Some(provider.id.clone());
}
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
match self
.forward(
app_type,
&method,
provider,
endpoint,
&provider_body,
&headers,
&extensions,
adapter.as_ref(),
)
.await
{
Ok((response, claude_api_format)) => {
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers = self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str() != provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await;
});
}
// 重新计算成功率
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
connection_guard: None,
});
}
Err(e) => {
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
let is_anthropic_provider = matches!(
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
let mut signature_rectifier_non_retryable_client_error = false;
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_signature(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if rectifier_retried {
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut provider_body);
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
);
signature_rectifier_non_retryable_client_error = true;
} else {
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(
app_type,
&method,
provider,
endpoint,
&provider_body,
&headers,
&extensions,
adapter.as_ref(),
)
.await
{
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
self.record_success_result(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests
as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
connection_guard: None,
});
}
Err(retry_err) => {
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
if let Some(err) = self
.handle_rectifier_retry_failure(
retry_err,
provider,
app_type_str,
used_half_open_permit,
"整流",
&mut last_error,
&mut last_provider,
)
.await
{
return Err(err);
}
continue;
}
}
}
}
}
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_budget(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if budget_rectifier_retried {
log::warn!(
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
);
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
let budget_rectified = rectify_thinking_budget(&mut provider_body);
if !budget_rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
);
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
log::info!(
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
app_type_str,
budget_rectified.before,
budget_rectified.after
);
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(
app_type,
&method,
provider,
endpoint,
&provider_body,
&headers,
&extensions,
adapter.as_ref(),
)
.await
{
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
self.record_success_result(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
connection_guard: None,
});
}
Err(retry_err) => {
log::warn!(
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
);
if let Some(err) = self
.handle_rectifier_retry_failure(
retry_err,
provider,
app_type_str,
used_half_open_permit,
"budget 整流",
&mut last_error,
&mut last_provider,
)
.await
{
return Err(err);
}
continue;
}
}
}
}
if signature_rectifier_non_retryable_client_error {
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 先分类错误,决定是否计入 provider 健康度
// —— NonRetryable / ClientAbort 是客户端层错误,无论换哪家 provider 都会被拒绝,
// 不应污染熔断器和数据库健康度(与 release_permit_neutral 同语义)。
let category = self.categorize_proxy_error(&e);
match category {
ErrorCategory::Retryable => {
// 可重试:真正的 provider 故障 → 记录失败并更新熔断器/DB 健康度
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(e.to_string()),
)
.await;
{
let mut status = self.status.write().await;
status.last_error =
Some(format!("Provider {} 失败: {}", provider.name, e));
}
let (log_code, log_message) = build_retryable_failure_log(
&provider.name,
attempted_providers,
providers.len(),
&e,
);
log::warn!("[{app_type_str}] [{log_code}] {log_message}");
last_error = Some(e);
last_provider = Some(provider.clone());
// 继续尝试下一个供应商
continue;
}
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
// 不可重试:客户端层错误或客户端断连 → 不污染健康度,仅释放 HalfOpen permit
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
}
}
}
}
if attempted_providers == 0 {
// providers 列表非空,但全部被熔断器拒绝(典型:HalfOpen 探测名额被占用)
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
return Err(ForwardError {
error: ProxyError::NoAvailableProvider,
provider: None,
});
}
// 所有供应商都失败了
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some("所有供应商都失败".to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
if let Some((log_code, log_message)) =
build_terminal_failure_log(attempted_providers, providers.len(), last_error.as_ref())
{
log::warn!("[{app_type_str}] [{log_code}] {log_message}");
}
Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider,
})
}
/// 转发单个请求(使用适配器)
#[allow(clippy::too_many_arguments)]
async fn forward(
&self,
app_type: &AppType,
method: &http::Method,
provider: &Provider,
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
extensions: &Extensions,
adapter: &dyn ProviderAdapter,
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
// 使用适配器提取 base_url
let mut base_url = adapter.extract_base_url(provider)?;
let is_full_url = provider
.meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
// 应用模型映射(独立于格式转换)
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider)
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?
} else {
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
mapped_body
};
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mut mapped_body = normalize_thinking_type(mapped_body);
if is_copilot {
mapped_body =
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
.await;
} else {
mapped_body =
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
}
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
//
// 执行顺序(与 copilot-api 对齐):
// 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user
// 2. 再清洗孤立 tool_result(防止上游 API 报错)
// 3. 再合并 tool_result + text(减少 premium 计费)
let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled {
// 1. 在原始 body 上分类 — 必须在清洗/合并之前执行
// 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent
let has_anthropic_beta = headers.contains_key("anthropic-beta");
let classification = super::copilot_optimizer::classify_request(
&mapped_body,
has_anthropic_beta,
self.copilot_optimizer_config.compact_detection,
self.copilot_optimizer_config.subagent_detection,
);
log::debug!(
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}",
classification.initiator,
classification.is_warmup,
classification.is_compact,
classification.is_subagent
);
// 2. 孤立 tool_result 清理 — 分类完成后再清洗
// 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费
mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body);
// 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)]
if self.copilot_optimizer_config.tool_result_merging {
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
}
// 3.5. 主动剥离 thinking block — Copilot 走 OpenAI 兼容端点不识别该块
// 避免上游拒绝后由 rectifier 反应式重试(首次请求已消耗 quota)
if self.copilot_optimizer_config.strip_thinking {
mapped_body = super::copilot_optimizer::strip_thinking_blocks(mapped_body);
}
// 4. Warmup 小模型降级
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
log::info!(
"[Copilot] Warmup 请求降级到模型: {}",
self.copilot_optimizer_config.warmup_model
);
mapped_body["model"] =
serde_json::json!(&self.copilot_optimizer_config.warmup_model);
}
// 预计算确定性 Request ID(在 body 被 move 之前)
// Session 提取优先级(与 session.rs extract_from_metadata 对齐):
// 1. metadata.user_id 中的 _session_ 后缀
// 2. metadata.session_id(直接字段)
// 3. raw metadata.user_id(整串 fallback
// 4. x-session-id header
let metadata = body.get("metadata");
let session_id = metadata
.and_then(|m| m.get("user_id"))
.and_then(|v| v.as_str())
.and_then(super::session::parse_session_from_user_id)
.or_else(|| {
metadata
.and_then(|m| m.get("session_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.or_else(|| {
metadata
.and_then(|m| m.get("user_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.or_else(|| {
headers
.get("x-session-id")
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.unwrap_or_default();
let det_request_id = if self.copilot_optimizer_config.deterministic_request_id {
Some(super::copilot_optimizer::deterministic_request_id(
&mapped_body,
&session_id,
))
} else {
None
};
// 从 session ID 派生稳定的 interaction ID(同一主对话共享)
let interaction_id =
super::copilot_optimizer::deterministic_interaction_id(&session_id);
Some((classification, det_request_id, interaction_id))
} else {
None
};
// GitHub Copilot 动态 endpoint 路由
// 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint)
if is_copilot && !is_full_url {
if let Some(app_handle) = &self.app_handle {
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
// 从 provider.meta 获取关联的 GitHub 账号 ID
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let dynamic_endpoint = match &account_id {
Some(id) => copilot_auth.get_api_endpoint(id).await,
None => copilot_auth.get_default_api_endpoint().await,
};
// 只在动态 endpoint 与当前 base_url 不同时替换
if dynamic_endpoint != base_url {
log::debug!(
"[Copilot] 使用动态 API endpoint: {} (原: {})",
dynamic_endpoint,
base_url
);
base_url = dynamic_endpoint;
}
}
}
let resolved_claude_api_format = if adapter.name() == "Claude" {
Some(
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
.await,
)
} else {
None
};
let needs_transform = match resolved_claude_api_format.as_deref() {
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
None => adapter.needs_transform(provider),
};
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
let (effective_endpoint, passthrough_query) = if codex_responses_to_chat {
rewrite_codex_responses_endpoint_to_chat(endpoint)
} else if needs_transform && adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
.as_deref()
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body)
} else {
(
endpoint.to_string(),
split_endpoint_and_query(endpoint)
.1
.map(ToString::to_string),
)
};
let codex_chat_base_is_full_endpoint = codex_responses_to_chat
&& base_url
.trim_end_matches('/')
.to_ascii_lowercase()
.ends_with("/chat/completions");
let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) {
super::gemini_url::resolve_gemini_native_url(
&base_url,
&effective_endpoint,
is_full_url,
)
} else if is_full_url || codex_chat_base_is_full_endpoint {
append_query_to_full_url(&base_url, passthrough_query.as_deref())
} else {
adapter.build_url(&base_url, &effective_endpoint)
};
// 转换请求体(如果需要)
let request_body = if codex_responses_to_chat {
let mut mapped_body = mapped_body;
let restored = self
.codex_chat_history
.enrich_request(&mut mapped_body)
.await;
if restored > 0 {
log::debug!(
"[Codex] Restored {restored} cached function call(s) for Chat upstream"
);
}
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
super::providers::transform_codex_chat::responses_to_chat_completions(mapped_body)?
} else if needs_transform {
if adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
.as_deref()
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
super::providers::transform_claude_request_for_api_format(
mapped_body,
provider,
api_format,
self.session_client_provided
.then_some(self.session_id.as_str()),
Some(self.gemini_shadow.as_ref()),
)?
} else {
adapter.transform_request(mapped_body, provider)?
}
} else {
mapped_body
};
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = prepare_upstream_request_body(request_body);
log_prompt_cache_trace(
app_type,
provider,
&effective_endpoint,
resolved_claude_api_format.as_deref(),
&filtered_body,
self.session_client_provided,
);
let request_is_streaming =
is_streaming_request(&effective_endpoint, &filtered_body, headers);
let force_identity_encoding =
needs_transform || codex_responses_to_chat || request_is_streaming;
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
let mut codex_oauth_account_id: Option<String> = None;
let mut should_send_codex_oauth_session_headers = false;
// 获取认证头(提前准备,用于内联替换)
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
if auth.strategy == AuthStrategy::GitHubCopilot {
if let Some(app_handle) = &self.app_handle {
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth: tokio::sync::RwLockReadGuard<'_, CopilotAuthManager> =
copilot_state.0.read().await;
// 从 provider.meta 获取关联的 GitHub 账号 ID(多账号支持)
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
// 根据账号 ID 获取对应 token(向后兼容:无账号 ID 时使用第一个账号)
let token_result = match &account_id {
Some(id) => {
log::debug!("[Copilot] 使用指定账号 {id} 获取 token");
copilot_auth.get_valid_token_for_account(id).await
}
None => {
log::debug!("[Copilot] 使用默认账号获取 token");
copilot_auth.get_valid_token().await
}
};
match token_result {
Ok(token) => {
auth = AuthInfo::new(token, AuthStrategy::GitHubCopilot);
log::debug!(
"[Copilot] 成功获取 Copilot token (account={})",
account_id.as_deref().unwrap_or("default")
);
}
Err(e) => {
log::error!(
"[Copilot] 获取 Copilot token 失败 (account={}): {e}",
account_id.as_deref().unwrap_or("default")
);
return Err(ProxyError::AuthError(format!(
"GitHub Copilot 认证失败: {e}"
)));
}
}
} else {
log::error!("[Copilot] AppHandle 不可用");
return Err(ProxyError::AuthError(
"GitHub Copilot 认证不可用(无 AppHandle".to_string(),
));
}
}
// Codex OAuth 特殊处理:从 CodexOAuthManager 获取真实 access_token
if auth.strategy == AuthStrategy::CodexOAuth {
if let Some(app_handle) = &self.app_handle {
let codex_state = app_handle.state::<CodexOAuthState>();
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
codex_state.0.read().await;
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("codex_oauth"));
let token_result = match &account_id {
Some(id) => {
log::debug!("[CodexOAuth] 使用指定账号 {id} 获取 token");
codex_auth.get_valid_token_for_account(id).await
}
None => {
log::debug!("[CodexOAuth] 使用默认账号获取 token");
codex_auth.get_valid_token().await
}
};
match token_result {
Ok(token) => {
auth = AuthInfo::new(token, AuthStrategy::CodexOAuth);
should_send_codex_oauth_session_headers = true;
// 解析使用的 account_id(用于注入 ChatGPT-Account-Id header
codex_oauth_account_id = match account_id {
Some(id) => Some(id),
None => codex_auth.default_account_id().await,
};
log::debug!(
"[CodexOAuth] 成功获取 access_token (account={})",
codex_oauth_account_id.as_deref().unwrap_or("default")
);
}
Err(e) => {
log::error!("[CodexOAuth] 获取 access_token 失败: {e}");
return Err(ProxyError::AuthError(format!(
"Codex OAuth 认证失败: {e}"
)));
}
}
} else {
log::error!("[CodexOAuth] AppHandle 不可用");
return Err(ProxyError::AuthError(
"Codex OAuth 认证不可用(无 AppHandle".to_string(),
));
}
}
adapter.get_auth_headers(&auth)?
} else {
Vec::new()
};
// 注入 Codex OAuth 的 ChatGPT-Account-Id header(如果有 account_id
if let Some(ref account_id) = codex_oauth_account_id {
if let Ok(hv) = http::HeaderValue::from_str(account_id) {
auth_headers.push((http::HeaderName::from_static("chatgpt-account-id"), hv));
}
}
let codex_oauth_session_headers =
if should_send_codex_oauth_session_headers && self.session_client_provided {
build_codex_oauth_session_headers(&self.session_id)
} else {
Vec::new()
};
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
copilot_optimization
{
for (name, value) in auth_headers.iter_mut() {
match name.as_str() {
"x-initiator" if self.copilot_optimizer_config.request_classification => {
*value = http::HeaderValue::from_static(classification.initiator);
}
"x-interaction-type" if classification.is_subagent => {
// 子代理请求:conversation-subagent 不计 premium interaction
*value = http::HeaderValue::from_static("conversation-subagent");
}
"x-request-id" | "x-agent-task-id" => {
if let Some(ref det_id) = det_request_id {
if let Ok(hv) = http::HeaderValue::from_str(det_id) {
*value = hv;
}
}
}
_ => {}
}
}
// x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中)
if let Some(ref iid) = interaction_id {
if let Ok(hv) = http::HeaderValue::from_str(iid) {
auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv));
}
}
if classification.is_subagent {
log::info!(
"[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent"
);
}
}
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
let copilot_fingerprint_headers: &[&str] = if is_copilot {
&[
"user-agent",
"editor-version",
"editor-plugin-version",
"copilot-integration-id",
"x-github-api-version",
"openai-intent",
// 新增 headers
"x-initiator",
"x-interaction-type",
"x-interaction-id",
"x-vscode-user-agent-library-version",
"x-request-id",
"x-agent-task-id",
]
} else {
&[]
};
// 预计算上游 host 值(用于在原位替换 host header
let upstream_host = url
.parse::<http::Uri>()
.ok()
.and_then(|u| u.authority().map(|a| a.to_string()));
let should_send_anthropic_headers = adapter.name() == "Claude"
&& matches!(resolved_claude_api_format.as_deref(), Some("anthropic"));
// 预计算 anthropic-beta 值(仅 Claude
let anthropic_beta_value = if should_send_anthropic_headers {
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
Some(if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
if beta_str.contains(CLAUDE_CODE_BETA) {
beta_str.to_string()
} else {
format!("{CLAUDE_CODE_BETA},{beta_str}")
}
} else {
CLAUDE_CODE_BETA.to_string()
}
} else {
CLAUDE_CODE_BETA.to_string()
})
} else {
None
};
// ============================================================
// 构建有序 HeaderMap — 内联替换,保持客户端原始顺序
// ============================================================
let mut ordered_headers = http::HeaderMap::new();
let mut saw_auth = false;
let mut saw_accept_encoding = false;
let mut saw_anthropic_beta = false;
let mut saw_anthropic_version = false;
for (key, value) in headers {
let key_str = key.as_str();
// --- host — 原位替换为上游 host(保持客户端原始位置) ---
if key_str.eq_ignore_ascii_case("host") {
if let Some(ref host_val) = upstream_host {
if let Ok(hv) = http::HeaderValue::from_str(host_val) {
ordered_headers.append(key.clone(), hv);
}
}
continue;
}
// --- 连接 / 追踪 / CDN 类 — 无条件跳过 ---
if matches!(
key_str,
"content-length"
| "transfer-encoding"
| "x-forwarded-host"
| "x-forwarded-port"
| "x-forwarded-proto"
| "forwarded"
| "cf-connecting-ip"
| "cf-ipcountry"
| "cf-ray"
| "cf-visitor"
| "true-client-ip"
| "fastly-client-ip"
| "x-azure-clientip"
| "x-azure-fdid"
| "x-azure-ref"
| "akamai-origin-hop"
| "x-akamai-config-log-detail"
| "x-request-id"
| "x-correlation-id"
| "x-trace-id"
| "x-amzn-trace-id"
| "x-b3-traceid"
| "x-b3-spanid"
| "x-b3-parentspanid"
| "x-b3-sampled"
| "traceparent"
| "tracestate"
) {
continue;
}
// --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) ---
if key_str.eq_ignore_ascii_case("authorization")
|| key_str.eq_ignore_ascii_case("x-api-key")
|| key_str.eq_ignore_ascii_case("x-goog-api-key")
{
if !saw_auth {
saw_auth = true;
for (ah_name, ah_value) in &auth_headers {
ordered_headers.append(ah_name.clone(), ah_value.clone());
}
}
continue;
}
// --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 ---
if key_str.eq_ignore_ascii_case("accept-encoding") {
if !saw_accept_encoding {
saw_accept_encoding = true;
if force_identity_encoding {
ordered_headers.append(
http::header::ACCEPT_ENCODING,
http::HeaderValue::from_static("identity"),
);
} else {
ordered_headers.append(key.clone(), value.clone());
}
}
continue;
}
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
if key_str.eq_ignore_ascii_case("anthropic-beta") {
if !saw_anthropic_beta {
saw_anthropic_beta = true;
if let Some(ref beta_val) = anthropic_beta_value {
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
ordered_headers.append("anthropic-beta", hv);
}
}
}
continue;
}
// --- anthropic-version — 透传客户端值 ---
if key_str.eq_ignore_ascii_case("anthropic-version") {
if should_send_anthropic_headers {
saw_anthropic_version = true;
ordered_headers.append(key.clone(), value.clone());
}
continue;
}
// --- Copilot 指纹头 — 跳过(由 auth_headers 提供) ---
if copilot_fingerprint_headers
.iter()
.any(|h| key_str.eq_ignore_ascii_case(h))
{
continue;
}
// --- 默认:透传 ---
ordered_headers.append(key.clone(), value.clone());
}
// 如果原始请求中没有认证头,在末尾追加
if !saw_auth && !auth_headers.is_empty() {
for (ah_name, ah_value) in &auth_headers {
ordered_headers.append(ah_name.clone(), ah_value.clone());
}
}
// transform / SSE 路径在缺失时补 identity;普通透传不主动补 accept-encoding
if !saw_accept_encoding && force_identity_encoding {
ordered_headers.append(
http::header::ACCEPT_ENCODING,
http::HeaderValue::from_static("identity"),
);
}
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
if !saw_anthropic_beta {
if let Some(ref beta_val) = anthropic_beta_value {
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
ordered_headers.append("anthropic-beta", hv);
}
}
}
// anthropic-version:仅在缺失时补充默认值
if should_send_anthropic_headers && !saw_anthropic_version {
ordered_headers.append(
"anthropic-version",
http::HeaderValue::from_static("2023-06-01"),
);
}
// Codex OAuth 反代尽量对齐官方 Codex CLI 的会话路由信号。
// 只发送客户端提供的 session_id;生成的 UUID 每次不同,反而会破坏前缀缓存。
for (name, value) in codex_oauth_session_headers {
ordered_headers.insert(name, value);
}
// 序列化请求体。GET/HEAD 是 idempotent/safe 方法,按 HTTP 语义不应携带 body;
// 强行附带 JSON body 会让某些上游(如 Google Gemini 的 models.list)拒绝请求。
let body_bytes = if matches!(method, &http::Method::GET | &http::Method::HEAD) {
Vec::new()
} else {
serde_json::to_vec(&filtered_body).map_err(|e| {
ProxyError::Internal(format!("Failed to serialize request body: {e}"))
})?
};
// 确保 content-type 存在
if !ordered_headers.contains_key(http::header::CONTENT_TYPE) {
ordered_headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/json"),
);
}
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
// 输出请求信息日志
let tag = adapter.name();
let request_model = filtered_body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
if log::log_enabled!(log::Level::Debug) {
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
body_str.len(),
body_str
);
}
}
// 确定超时
let timeout = if self.non_streaming_timeout.is_zero() {
std::time::Duration::from_secs(600) // 默认 600 秒
} else {
self.non_streaming_timeout
};
// 获取全局代理 URL
let upstream_proxy_url: Option<String> = super::http_client::get_current_proxy_url();
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
let is_socks_proxy = upstream_proxy_url
.as_deref()
.map(|u| u.starts_with("socks5"))
.unwrap_or(false);
let preserve_exact_header_case = should_preserve_exact_header_case(
adapter.name(),
provider,
resolved_claude_api_format.as_deref(),
is_copilot,
);
// 发送请求
let response = if is_socks_proxy || !preserve_exact_header_case {
// OpenAI / Copilot / Codex 类后端不依赖原始 header 大小写;走 reqwest
// 连接池,避免 raw TCP/TLS path 每次请求都重新握手。SOCKS5 也只能走 reqwest。
log::debug!(
"[Forwarder] Using pooled reqwest client (preserve_exact_header_case={preserve_exact_header_case}, socks_proxy={is_socks_proxy})"
);
let client = super::http_client::get();
let mut request = client.request(method.clone(), &url);
if request_is_streaming {
// reqwest 的 timeout 是整请求超时;流式请求交给 response_processor
// 的首包/静默期超时控制,避免长流被总时长误杀。
request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60));
} else if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
for (key, value) in &ordered_headers {
request = request.header(key, value);
}
let send = request.body(body_bytes).send();
let send_result = if request_is_streaming {
let header_timeout = if self.streaming_first_byte_timeout.is_zero() {
timeout
} else {
self.streaming_first_byte_timeout
};
tokio::time::timeout(header_timeout, send)
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"流式响应首包超时: {}s(上游未返回响应头)",
header_timeout.as_secs()
))
})?
} else {
send.await
};
let reqwest_resp = send_result.map_err(map_reqwest_send_error)?;
ProxyResponse::Reqwest(reqwest_resp)
} else {
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
super::hyper_client::send_request(
uri,
method.clone(),
ordered_headers,
extensions.clone(),
body_bytes,
timeout,
upstream_proxy_url.as_deref(),
)
.await?
};
// 检查响应状态
let status = response.status();
if status.is_success() {
let response = self
.prepare_success_response_for_failover(response, request_is_streaming)
.await?;
Ok((response, resolved_claude_api_format))
} else {
let status_code = status.as_u16();
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
Err(ProxyError::UpstreamError {
status: status_code,
body: body_text,
})
}
}
/// 故障转移开启时,成功不能只看上游响应头。
///
/// - 非流式:先把完整 body 读到内存,读超时/连接中断会回到 retry loop 尝试下一家。
/// - 流式:至少等首个 chunk 到达,避免上游返回 200 后一直不吐 SSE 时被误记成功。
async fn prepare_success_response_for_failover(
&self,
response: ProxyResponse,
request_is_streaming: bool,
) -> Result<ProxyResponse, ProxyError> {
if request_is_streaming {
return self.prime_streaming_response(response).await;
}
if self.non_streaming_timeout.is_zero() {
return Ok(response);
}
let status = response.status();
let headers = response.headers().clone();
let body_timeout = self.non_streaming_timeout;
let body = tokio::time::timeout(body_timeout, response.bytes())
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
body_timeout.as_secs()
))
})??;
Ok(ProxyResponse::buffered(status, headers, body))
}
async fn prime_streaming_response(
&self,
response: ProxyResponse,
) -> Result<ProxyResponse, ProxyError> {
if self.streaming_first_byte_timeout.is_zero() {
return Ok(response);
}
let status = response.status();
let headers = response.headers().clone();
let timeout = self.streaming_first_byte_timeout;
let mut stream = Box::pin(response.bytes_stream());
let first = tokio::time::timeout(timeout, stream.next())
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"流式响应首包超时: {}s(上游已返回响应头但未返回数据)",
timeout.as_secs()
))
})?;
let Some(first) = first else {
return Err(ProxyError::ForwardFailed(
"流式响应在首包到达前结束".to_string(),
));
};
let first =
first.map_err(|e| ProxyError::ForwardFailed(format!("读取流式响应首包失败: {e}")))?;
let replay = futures::stream::once(async move { Ok(first) }).chain(stream);
Ok(ProxyResponse::streamed(status, headers, replay))
}
async fn resolve_claude_api_format(
&self,
provider: &Provider,
body: &Value,
is_copilot: bool,
) -> String {
if !is_copilot {
return super::providers::get_claude_api_format(provider).to_string();
}
let model = body.get("model").and_then(|value| value.as_str());
if let Some(model_id) = model {
if self
.is_copilot_openai_vendor_model(provider, model_id)
.await
{
return "openai_responses".to_string();
}
}
"openai_chat".to_string()
}
/// 用 Copilot live `/models` 列表确认 model ID 真实可用,找不到时按 family 降级。
/// 命中缓存后是同步的;首次请求或 5 min 缓存过期后会触发一次 HTTP。
async fn apply_copilot_live_model_resolution(
&self,
provider: &Provider,
body: &mut serde_json::Value,
) {
let Some(model_id) = body.get("model").and_then(|v| v.as_str()) else {
return;
};
let model_id = model_id.to_string();
let Some(app_handle) = &self.app_handle else {
return;
};
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let models_result = match account_id.as_deref() {
Some(id) => copilot_auth.fetch_models_for_account(id).await,
None => copilot_auth.fetch_models().await,
};
let models = match models_result {
Ok(m) => m,
Err(err) => {
log::debug!("[Copilot] live model list unavailable, skip resolution: {err}");
return;
}
};
if let Some(resolved) =
super::providers::copilot_model_map::resolve_against_models(&model_id, &models)
{
log::info!("[Copilot] live-model resolve: {model_id} → {resolved}");
body["model"] = serde_json::Value::String(resolved);
}
}
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
let Some(app_handle) = &self.app_handle else {
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
return false;
};
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let vendor_result = match account_id.as_deref() {
Some(id) => {
copilot_auth
.get_model_vendor_for_account(id, model_id)
.await
}
None => copilot_auth.get_model_vendor(model_id).await,
};
match vendor_result {
Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"),
Ok(None) => {
log::debug!(
"[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions"
);
false
}
Err(err) => {
log::warn!(
"[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}"
);
false
}
}
}
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error {
// 网络和上游错误:都应该尝试下一个供应商
ProxyError::Timeout(_) => ErrorCategory::Retryable,
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
// 上游 HTTP 错误:按状态码分桶。
//
// 客户端请求自身有问题的状态码无论换哪个 provider 都会被拒绝,
// 继续轮询只会放大错误率、污染熔断器健康度、浪费配额:
// 400 Bad Request / 422 Unprocessable Entity ← 请求体格式或语义错误
// 405 Method Not Allowed / 406 Not Acceptable ← 方法或 Accept 错误
// 413 Payload Too Large / 414 URI Too Long ← 客户端构造超限
// 415 Unsupported Media Type ← Content-Type 错误
// 501 Not Implemented ← 上游协议确实不支持
//
// 其他 4xx401/403/404/408/409/429/451 等)和全部 5xx 都保留
// Retryable —— 换一家 provider 可能持有不同的 key、配额、地域或模型映射。
ProxyError::UpstreamError { status, .. } => match *status {
400 | 405 | 406 | 413 | 414 | 415 | 422 | 501 => ErrorCategory::NonRetryable,
_ => ErrorCategory::Retryable,
},
// Provider 级配置/转换问题:换一个 Provider 可能就能成功
ProxyError::ConfigError(_) => ErrorCategory::Retryable,
ProxyError::TransformError(_) => ErrorCategory::Retryable,
ProxyError::AuthError(_) => ErrorCategory::Retryable,
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
// 无可用供应商:所有供应商都试过了,无法重试
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
_ => ErrorCategory::NonRetryable,
}
}
}
/// 从 ProxyError 中提取错误消息
fn extract_error_message(error: &ProxyError) -> Option<String> {
match error {
ProxyError::UpstreamError { body, .. } => body.clone(),
_ => Some(error.to_string()),
}
}
/// 检测 Provider 是否为 Bedrock(通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断)
fn is_bedrock_provider(provider: &Provider) -> bool {
provider
.settings_config
.get("env")
.and_then(|e| e.get("CLAUDE_CODE_USE_BEDROCK"))
.and_then(|v| v.as_str())
.map(|v| v == "1")
.unwrap_or(false)
}
fn build_retryable_failure_log(
provider_name: &str,
attempted_providers: usize,
total_providers: usize,
error: &ProxyError,
) -> (&'static str, String) {
let error_summary = summarize_proxy_error(error);
if total_providers <= 1 {
(
log_fwd::SINGLE_PROVIDER_FAILED,
format!("Provider {provider_name} 请求失败: {error_summary}"),
)
} else {
(
log_fwd::PROVIDER_FAILED_RETRY,
format!(
"Provider {provider_name} 失败,继续尝试下一个 ({attempted_providers}/{total_providers}): {error_summary}"
),
)
}
}
fn build_terminal_failure_log(
attempted_providers: usize,
total_providers: usize,
last_error: Option<&ProxyError>,
) -> Option<(&'static str, String)> {
if total_providers <= 1 {
return None;
}
let error_summary = last_error
.map(summarize_proxy_error)
.unwrap_or_else(|| "未知错误".to_string());
Some((
log_fwd::ALL_PROVIDERS_FAILED,
format!(
"已尝试 {attempted_providers}/{total_providers} 个 Provider,均失败。最后错误: {error_summary}"
),
))
}
fn summarize_proxy_error(error: &ProxyError) -> String {
match error {
ProxyError::UpstreamError { status, body } => {
let body_summary = body
.as_deref()
.map(summarize_upstream_body)
.filter(|summary| !summary.is_empty());
match body_summary {
Some(summary) => format!("上游 HTTP {status}: {summary}"),
None => format!("上游 HTTP {status}"),
}
}
ProxyError::Timeout(message) => {
format!("请求超时: {}", summarize_text_for_log(message, 180))
}
ProxyError::ForwardFailed(message) => {
format!("请求转发失败: {}", summarize_text_for_log(message, 180))
}
ProxyError::TransformError(message) => {
format!("响应转换失败: {}", summarize_text_for_log(message, 180))
}
ProxyError::ConfigError(message) => {
format!("配置错误: {}", summarize_text_for_log(message, 180))
}
ProxyError::AuthError(message) => {
format!("认证失败: {}", summarize_text_for_log(message, 180))
}
_ => summarize_text_for_log(&error.to_string(), 180),
}
}
fn summarize_upstream_body(body: &str) -> String {
if let Ok(json_body) = serde_json::from_str::<Value>(body) {
if let Some(message) = extract_json_error_message(&json_body) {
return summarize_text_for_log(&message, 180);
}
if let Ok(compact_json) = serde_json::to_string(&json_body) {
return summarize_text_for_log(&compact_json, 180);
}
}
summarize_text_for_log(body, 180)
}
fn extract_json_error_message(body: &Value) -> Option<String> {
let candidates = [
body.pointer("/error/message"),
body.pointer("/message"),
body.pointer("/detail"),
body.pointer("/error"),
];
candidates
.into_iter()
.flatten()
.find_map(|value| value.as_str().map(ToString::to_string))
}
fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) {
endpoint
.split_once('?')
.map_or((endpoint, None), |(path, query)| (path, Some(query)))
}
fn strip_beta_query(query: Option<&str>) -> Option<String> {
let filtered = query.map(|query| {
query
.split('&')
.filter(|pair| !pair.is_empty() && !pair.starts_with("beta="))
.collect::<Vec<_>>()
.join("&")
});
match filtered.as_deref() {
Some("") | None => None,
Some(_) => filtered,
}
}
fn is_claude_messages_path(path: &str) -> bool {
matches!(path, "/v1/messages" | "/claude/v1/messages")
}
fn rewrite_codex_responses_endpoint_to_chat(endpoint: &str) -> (String, Option<String>) {
let (_path, query) = split_endpoint_and_query(endpoint);
let passthrough_query = query.map(ToString::to_string);
let target_path = "/chat/completions";
let rewritten = match passthrough_query.as_deref() {
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
_ => target_path.to_string(),
};
(rewritten, passthrough_query)
}
fn rewrite_claude_transform_endpoint(
endpoint: &str,
api_format: &str,
is_copilot: bool,
body: &Value,
) -> (String, Option<String>) {
let (path, query) = split_endpoint_and_query(endpoint);
let passthrough_query = if is_claude_messages_path(path) {
strip_beta_query(query)
} else {
query.map(ToString::to_string)
};
if !is_claude_messages_path(path) {
return (endpoint.to_string(), passthrough_query);
}
if api_format == "gemini_native" {
let model =
super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown");
// Accept both bare ids (`gemini-2.5-pro`) and the resource-name
// form (`models/gemini-2.5-pro`) that Gemini SDKs emit. See
// `normalize_gemini_model_id` for rationale.
let model = super::gemini_url::normalize_gemini_model_id(model);
let is_stream = body
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let target_path = if is_stream {
format!("/v1beta/models/{model}:streamGenerateContent")
} else {
format!("/v1beta/models/{model}:generateContent")
};
let rewritten_query = merge_query_params(
passthrough_query.as_deref(),
if is_stream { Some("alt=sse") } else { None },
);
let rewritten = match rewritten_query.as_deref() {
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
_ => target_path,
};
return (rewritten, rewritten_query);
}
let target_path = if is_copilot && api_format == "openai_responses" {
"/v1/responses"
} else if is_copilot {
"/chat/completions"
} else if api_format == "openai_responses" {
"/v1/responses"
} else {
"/v1/chat/completions"
};
let rewritten = match passthrough_query.as_deref() {
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
_ => target_path.to_string(),
};
(rewritten, passthrough_query)
}
fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option<String> {
let mut params: Vec<String> = base_query
.into_iter()
.flat_map(|query| query.split('&'))
.filter(|pair| !pair.is_empty())
.filter(|pair| !pair.starts_with("alt="))
.map(ToString::to_string)
.collect();
if let Some(extra_param) = extra_param {
params.push(extra_param.to_string());
}
if params.is_empty() {
None
} else {
Some(params.join("&"))
}
}
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
match query {
Some(query) if !query.is_empty() => {
if base_url.contains('?') {
format!("{base_url}&{query}")
} else {
format!("{base_url}?{query}")
}
}
_ => base_url.to_string(),
}
}
fn build_codex_oauth_session_headers(
session_id: &str,
) -> Vec<(http::HeaderName, http::HeaderValue)> {
let session_id = session_id.trim();
if session_id.is_empty() {
return Vec::new();
}
let mut headers = Vec::new();
if let Ok(value) = http::HeaderValue::from_str(session_id) {
headers.push((http::HeaderName::from_static("session_id"), value.clone()));
headers.push((http::HeaderName::from_static("x-client-request-id"), value));
}
let window_id = format!("{session_id}:0");
if let Ok(value) = http::HeaderValue::from_str(&window_id) {
headers.push((http::HeaderName::from_static("x-codex-window-id"), value));
}
headers
}
fn reject_proxy_placeholder_for_managed_account_upstream(
url: &str,
headers: &http::HeaderMap,
) -> Result<(), ProxyError> {
if !is_managed_account_upstream_url(url) || !headers_contain_proxy_placeholder(headers) {
return Ok(());
}
Err(ProxyError::AuthError(
"Managed account proxy auth was not resolved; PROXY_MANAGED must not be sent upstream"
.to_string(),
))
}
fn is_managed_account_upstream_url(url: &str) -> bool {
let Ok(uri) = url.parse::<http::Uri>() else {
return false;
};
let Some(host) = uri.host().map(str::to_ascii_lowercase) else {
return false;
};
host == "githubcopilot.com"
|| host.ends_with(".githubcopilot.com")
|| (host == "chatgpt.com" && uri.path().starts_with("/backend-api/codex"))
}
fn headers_contain_proxy_placeholder(headers: &http::HeaderMap) -> bool {
headers.values().any(|value| {
value
.to_str()
.map(|value| value.contains(PROXY_AUTH_PLACEHOLDER))
.unwrap_or(false)
})
}
fn should_preserve_exact_header_case(
adapter_name: &str,
provider: &Provider,
resolved_claude_api_format: Option<&str>,
is_copilot: bool,
) -> bool {
if matches!(adapter_name, "Codex" | "Gemini") {
return false;
}
if is_copilot || provider.is_codex_oauth() {
return false;
}
matches!(resolved_claude_api_format, None | Some("anthropic"))
}
fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::HeaderMap) -> bool {
if body
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
return true;
}
if endpoint.contains("streamGenerateContent") || endpoint.contains("alt=sse") {
return true;
}
headers
.get(axum::http::header::ACCEPT)
.and_then(|value| value.to_str().ok())
.map(|accept| accept.contains("text/event-stream"))
.unwrap_or(false)
}
#[cfg(test)]
fn should_force_identity_encoding(
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
) -> bool {
is_streaming_request(endpoint, body, headers)
}
fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError {
if error.is_timeout() {
ProxyError::Timeout(format!("请求超时: {error}"))
} else if error.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {error}"))
} else {
ProxyError::ForwardFailed(error.to_string())
}
}
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
if trimmed.chars().count() <= max_chars {
return trimmed.to_string();
}
let truncated: String = trimmed.chars().take(max_chars).collect();
let truncated = truncated.trim_end();
format!("{truncated}...")
}
fn prepare_upstream_request_body(request_body: Value) -> Value {
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
}
fn log_prompt_cache_trace(
app_type: &AppType,
provider: &Provider,
endpoint: &str,
api_format: Option<&str>,
body: &Value,
session_client_provided: bool,
) {
if !log::log_enabled!(log::Level::Debug) {
return;
}
let prompt_cache_key = body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(|key| format!("present(len={})", key.len()))
.unwrap_or_else(|| "absent".to_string());
let store = body
.get("store")
.map(value_for_log)
.unwrap_or_else(|| "absent".to_string());
let stream = body
.get("stream")
.map(value_for_log)
.unwrap_or_else(|| "absent".to_string());
log::debug!(
"[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, tools_hash={}, input_hash={}, include_hash={}, body_hash={}",
app_type.as_str(),
provider.id,
endpoint,
api_format.unwrap_or("native"),
session_client_provided,
prompt_cache_key,
store,
stream,
short_value_hash(body.get("instructions")),
short_value_hash(body.get("tools")),
short_value_hash(body.get("input")),
short_value_hash(body.get("include")),
short_value_hash(Some(body)),
);
}
fn value_for_log(value: &Value) -> String {
match value {
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::String(value) => value.clone(),
Value::Null => "null".to_string(),
Value::Array(values) => format!("array(len={})", values.len()),
Value::Object(values) => format!("object(len={})", values.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use axum::http::header::{HeaderValue, ACCEPT};
use axum::http::HeaderMap;
use bytes::Bytes;
use http::StatusCode;
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
fn test_provider_with_type(provider_type: Option<&str>) -> Provider {
Provider {
id: "provider-1".to_string(),
name: "Provider 1".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: provider_type.map(|value| crate::provider::ProviderMeta {
provider_type: Some(value.to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn test_forwarder(
non_streaming_timeout: Duration,
streaming_first_byte_timeout: Duration,
) -> RequestForwarder {
let db = Arc::new(Database::memory().expect("memory db"));
RequestForwarder {
router: Arc::new(ProviderRouter::new(db.clone())),
status: Arc::new(RwLock::new(ProxyStatus::default())),
current_providers: Arc::new(RwLock::new(HashMap::new())),
gemini_shadow: Arc::new(GeminiShadowStore::new()),
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
app_handle: None,
current_provider_id_at_start: String::new(),
session_id: String::new(),
session_client_provided: false,
rectifier_config: RectifierConfig::default(),
optimizer_config: OptimizerConfig::default(),
copilot_optimizer_config: CopilotOptimizerConfig::default(),
non_streaming_timeout,
streaming_first_byte_timeout,
max_attempts: 1,
}
}
#[test]
fn single_provider_retryable_log_uses_single_provider_code() {
let error = ProxyError::UpstreamError {
status: 429,
body: Some(r#"{"error":{"message":"rate limit exceeded"}}"#.to_string()),
};
let (code, message) = build_retryable_failure_log("PackyCode-response", 1, 1, &error);
assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED);
assert!(message.contains("Provider PackyCode-response 请求失败"));
assert!(message.contains("上游 HTTP 429"));
assert!(message.contains("rate limit exceeded"));
assert!(!message.contains("切换下一个"));
}
#[test]
fn multi_provider_retryable_log_keeps_failover_wording() {
let error = ProxyError::Timeout("upstream timed out after 30s".to_string());
let (code, message) = build_retryable_failure_log("primary", 1, 3, &error);
assert_eq!(code, log_fwd::PROVIDER_FAILED_RETRY);
assert!(message.contains("继续尝试下一个 (1/3)"));
assert!(message.contains("请求超时"));
}
#[test]
fn single_provider_has_no_terminal_all_failed_log() {
assert!(build_terminal_failure_log(1, 1, None).is_none());
}
#[test]
fn multi_provider_terminal_log_contains_last_error_summary() {
let error = ProxyError::ForwardFailed("connection reset by peer".to_string());
let (code, message) =
build_terminal_failure_log(2, 2, Some(&error)).expect("expected terminal log");
assert_eq!(code, log_fwd::ALL_PROVIDERS_FAILED);
assert!(message.contains("已尝试 2/2 个 Provider,均失败"));
assert!(message.contains("connection reset by peer"));
}
#[test]
fn summarize_upstream_body_prefers_json_message() {
let body = json!({
"error": {
"message": "invalid_request_error: unsupported field"
},
"request_id": "req_123"
});
let summary = summarize_upstream_body(&body.to_string());
assert_eq!(summary, "invalid_request_error: unsupported field");
}
#[test]
fn summarize_text_for_log_collapses_whitespace_and_truncates() {
let summary = summarize_text_for_log("line1\n\n line2 line3", 12);
assert_eq!(summary, "line1 line2...");
}
#[test]
fn canonical_json_sorts_object_keys_for_cache_trace_hashes() {
let left = json!({
"tools": [
{
"parameters": {
"properties": {
"b": {"type": "string"},
"a": {"type": "number"}
},
"type": "object"
},
"name": "lookup"
}
]
});
let right = json!({
"tools": [
{
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "string"}
}
}
}
]
});
assert_eq!(
crate::proxy::json_canonical::canonical_json_string(&left),
crate::proxy::json_canonical::canonical_json_string(&right)
);
assert_eq!(
short_value_hash(Some(&left)),
short_value_hash(Some(&right))
);
}
#[test]
fn prepare_upstream_request_body_filters_private_fields_and_canonicalizes_order() {
let body = json!({
"z": 1,
"_internal": "drop",
"tools": [
{
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"_id": {
"_private_note": "drop",
"type": "string"
},
"b": {"type": "number"},
"a": {"type": "string"}
}
}
}
],
"a": 2
});
let prepared = prepare_upstream_request_body(body);
assert!(prepared.get("_internal").is_none());
assert!(prepared["tools"][0]["parameters"]["properties"]
.get("_id")
.is_some());
assert!(prepared["tools"][0]["parameters"]["properties"]["_id"]
.get("_private_note")
.is_none());
assert_eq!(
serde_json::to_string(&prepared).unwrap(),
r#"{"a":2,"tools":[{"name":"lookup","parameters":{"properties":{"_id":{"type":"string"},"a":{"type":"string"},"b":{"type":"number"}},"type":"object"}}],"z":1}"#
);
}
#[tokio::test]
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
let response = ProxyResponse::streamed(
StatusCode::OK,
HeaderMap::new(),
futures::stream::once(async {
tokio::time::sleep(Duration::from_millis(10)).await;
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"{\"ok\":true}"))
}),
);
let prepared = forwarder
.prepare_success_response_for_failover(response, false)
.await
.expect("response should be buffered");
assert_eq!(
prepared.bytes().await.unwrap(),
Bytes::from_static(b"{\"ok\":true}")
);
}
#[tokio::test]
async fn non_streaming_body_read_error_is_retryable_before_success_record() {
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
let response = ProxyResponse::streamed(
StatusCode::OK,
HeaderMap::new(),
futures::stream::once(async {
Err::<Bytes, std::io::Error>(std::io::Error::other("body boom"))
}),
);
let err = match forwarder
.prepare_success_response_for_failover(response, false)
.await
{
Ok(_) => panic!("body read errors should fail the attempt"),
Err(err) => err,
};
assert!(matches!(err, ProxyError::ForwardFailed(_)));
}
#[tokio::test]
async fn streaming_success_primes_first_chunk_and_replays_it() {
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
let response = ProxyResponse::streamed(
StatusCode::OK,
HeaderMap::new(),
futures::stream::iter(vec![
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"first")),
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"second")),
]),
);
let prepared = forwarder
.prepare_success_response_for_failover(response, true)
.await
.expect("stream should be primed");
assert_eq!(
prepared.bytes().await.unwrap(),
Bytes::from_static(b"firstsecond")
);
}
#[tokio::test]
async fn streaming_first_chunk_error_is_retryable_before_success_record() {
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
let response = ProxyResponse::streamed(
StatusCode::OK,
HeaderMap::new(),
futures::stream::once(async {
Err::<Bytes, std::io::Error>(std::io::Error::other("first chunk boom"))
}),
);
let err = match forwarder
.prepare_success_response_for_failover(response, true)
.await
{
Ok(_) => panic!("first chunk errors should fail the attempt"),
Err(err) => err,
};
assert!(matches!(err, ProxyError::ForwardFailed(_)));
}
#[test]
fn codex_oauth_session_headers_match_codex_cache_identity() {
let headers = build_codex_oauth_session_headers("session-123");
let mut map = HeaderMap::new();
for (name, value) in headers {
map.insert(name, value);
}
assert_eq!(
map.get("session_id"),
Some(&HeaderValue::from_static("session-123"))
);
assert_eq!(
map.get("x-client-request-id"),
Some(&HeaderValue::from_static("session-123"))
);
assert_eq!(
map.get("x-codex-window-id"),
Some(&HeaderValue::from_static("session-123:0"))
);
}
#[test]
fn managed_account_upstream_rejects_proxy_managed_placeholder_header() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer PROXY_MANAGED"),
);
let err = reject_proxy_placeholder_for_managed_account_upstream(
"https://api.githubcopilot.com/chat/completions",
&headers,
)
.expect_err("placeholder should be rejected before upstream");
assert!(matches!(
err,
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
));
}
#[test]
fn codex_oauth_upstream_rejects_proxy_managed_placeholder_header() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer PROXY_MANAGED"),
);
let err = reject_proxy_placeholder_for_managed_account_upstream(
"https://chatgpt.com/backend-api/codex/responses",
&headers,
)
.expect_err("placeholder should be rejected before upstream");
assert!(matches!(
err,
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
));
}
#[test]
fn non_managed_upstream_allows_proxy_managed_placeholder_guard() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer PROXY_MANAGED"),
);
reject_proxy_placeholder_for_managed_account_upstream(
"https://api.example.com/v1/messages",
&headers,
)
.expect("guard is scoped to managed-account upstreams");
}
#[test]
fn exact_header_case_preserved_for_native_claude_only() {
let provider = test_provider_with_type(None);
assert!(should_preserve_exact_header_case(
"Claude",
&provider,
Some("anthropic"),
false
));
assert!(!should_preserve_exact_header_case(
"Claude",
&provider,
Some("openai_responses"),
false
));
assert!(!should_preserve_exact_header_case(
"Codex", &provider, None, false
));
assert!(!should_preserve_exact_header_case(
"Gemini", &provider, None, false
));
}
#[test]
fn exact_header_case_skipped_for_codex_oauth_and_copilot() {
let codex_oauth = test_provider_with_type(Some("codex_oauth"));
let copilot = test_provider_with_type(Some("github_copilot"));
assert!(!should_preserve_exact_header_case(
"Claude",
&codex_oauth,
Some("openai_responses"),
false
));
assert!(!should_preserve_exact_header_case(
"Claude",
&copilot,
Some("openai_chat"),
true
));
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&foo=bar",
"openai_chat",
false,
&json!({ "model": "gpt-5.4" }),
);
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_responses() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/claude/v1/messages?beta=true&x-id=1",
"openai_responses",
false,
&json!({ "model": "gpt-5.4" }),
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn rewrite_codex_responses_endpoint_to_chat_preserves_query() {
let (endpoint, passthrough_query) =
rewrite_codex_responses_endpoint_to_chat("/v1/responses?foo=bar");
assert_eq!(endpoint, "/chat/completions?foo=bar");
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() {
let (endpoint, passthrough_query) =
rewrite_codex_responses_endpoint_to_chat("/v1/responses/compact?foo=bar");
assert_eq!(endpoint, "/chat/completions?foo=bar");
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&x-id=1",
"anthropic",
true,
&json!({ "model": "claude-sonnet-4-6" }),
);
assert_eq!(endpoint, "/chat/completions?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&x-id=1",
"openai_responses",
true,
&json!({ "model": "gpt-5.4" }),
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&x-id=1",
"gemini_native",
false,
&json!({ "model": "gemini-2.5-pro" }),
);
assert_eq!(
endpoint,
"/v1beta/models/gemini-2.5-pro:generateContent?x-id=1"
);
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
/// Regression: body.model arriving as the resource-name form
/// `models/gemini-2.5-pro` must not produce a doubled
/// `/v1beta/models/models/...` path.
#[test]
fn rewrite_claude_transform_endpoint_strips_gemini_model_resource_prefix() {
let (endpoint, _) = rewrite_claude_transform_endpoint(
"/v1/messages",
"gemini_native",
false,
&json!({ "model": "models/gemini-2.5-pro" }),
);
assert_eq!(endpoint, "/v1beta/models/gemini-2.5-pro:generateContent");
}
#[test]
fn rewrite_claude_transform_endpoint_maps_gemini_streaming() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true",
"gemini_native",
false,
&json!({ "model": "gemini-2.5-flash", "stream": true }),
);
assert_eq!(
endpoint,
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
assert_eq!(passthrough_query.as_deref(), Some("alt=sse"));
}
#[test]
fn append_query_to_full_url_preserves_existing_query_string() {
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
}
#[test]
fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() {
let url = crate::proxy::gemini_url::build_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta",
"/v1beta/models/gemini-2.5-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
);
}
#[test]
fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() {
let url = crate::proxy::gemini_url::build_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta/models",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
#[test]
fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() {
let url = crate::proxy::gemini_url::resolve_gemini_native_url(
"https://relay.example/custom/generate-content",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
#[test]
fn force_identity_for_stream_flag_requests() {
let headers = HeaderMap::new();
assert!(should_force_identity_encoding(
"/v1/responses",
&json!({ "stream": true }),
&headers
));
}
#[test]
fn force_identity_for_gemini_stream_endpoints() {
let headers = HeaderMap::new();
assert!(should_force_identity_encoding(
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
&json!({ "model": "gemini-2.5-pro" }),
&headers
));
}
#[test]
fn streaming_request_detects_gemini_sse_without_body_stream_flag() {
let headers = HeaderMap::new();
assert!(is_streaming_request(
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
&json!({ "model": "gemini-2.5-pro" }),
&headers
));
}
#[test]
fn force_identity_for_sse_accept_header() {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
assert!(should_force_identity_encoding(
"/v1/responses",
&json!({ "model": "gpt-5" }),
&headers
));
}
#[test]
fn non_streaming_requests_allow_automatic_compression() {
let headers = HeaderMap::new();
assert!(!should_force_identity_encoding(
"/v1/responses",
&json!({ "model": "gpt-5" }),
&headers
));
}
// ==================== Copilot 动态 endpoint 路由相关测试 ====================
/// 验证 is_copilot 检测逻辑:通过 provider_type 判断
#[test]
fn copilot_detection_via_provider_type() {
use crate::provider::{Provider, ProviderMeta};
let provider = Provider {
id: "test".to_string(),
name: "Test Copilot".to_string(),
settings_config: serde_json::json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
};
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot");
assert!(is_copilot, "应该通过 provider_type 检测为 Copilot");
}
/// 验证 is_copilot 检测逻辑:通过 base_url 判断
#[test]
fn copilot_detection_via_base_url() {
let base_url = "https://api.githubcopilot.com";
let is_copilot = base_url.contains("githubcopilot.com");
assert!(is_copilot, "应该通过 base_url 检测为 Copilot");
let non_copilot_url = "https://api.anthropic.com";
let is_not_copilot = non_copilot_url.contains("githubcopilot.com");
assert!(!is_not_copilot, "非 Copilot URL 不应被检测为 Copilot");
}
/// 验证企业版 endpoint(不包含 githubcopilot.com)场景下 is_copilot 仍然正确
#[test]
fn copilot_detection_for_enterprise_endpoint() {
use crate::provider::{Provider, ProviderMeta};
// 企业版场景:provider_type 是 github_copilot,但 base_url 可能是企业内部域名
let provider = Provider {
id: "enterprise".to_string(),
name: "Enterprise Copilot".to_string(),
settings_config: serde_json::json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
};
let enterprise_base_url = "https://copilot-api.corp.example.com";
// is_copilot 应该通过 provider_type 检测成功,即使 base_url 不包含 githubcopilot.com
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| enterprise_base_url.contains("githubcopilot.com");
assert!(
is_copilot,
"企业版 Copilot 应该通过 provider_type 被正确检测"
);
}
/// 验证动态 endpoint 替换条件
#[test]
fn dynamic_endpoint_replacement_conditions() {
// 条件:is_copilot && !is_full_url
let test_cases = [
(true, false, true, "Copilot + 非 full_url 应该替换"),
(true, true, false, "Copilot + full_url 不应替换"),
(false, false, false, "非 Copilot 不应替换"),
(false, true, false, "非 Copilot + full_url 不应替换"),
];
for (is_copilot, is_full_url, should_replace, desc) in test_cases {
let will_replace = is_copilot && !is_full_url;
assert_eq!(will_replace, should_replace, "{desc}");
}
}
}