mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
Merge branch 'main' into codex/issue-1888-interception-matrix
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,11 @@ use super::{
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::commands::CopilotAuthState;
|
||||
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 http::Extensions;
|
||||
@@ -52,6 +53,8 @@ pub struct RequestForwarder {
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
@@ -70,6 +73,7 @@ impl RequestForwarder {
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
router,
|
||||
@@ -80,6 +84,7 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
@@ -760,7 +765,7 @@ impl RequestForwarder {
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
@@ -771,6 +776,102 @@ impl RequestForwarder {
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -857,8 +958,11 @@ impl RequestForwarder {
|
||||
let force_identity_encoding = needs_transform
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
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 {
|
||||
@@ -909,11 +1013,109 @@ impl RequestForwarder {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 解析使用的 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));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
&[
|
||||
@@ -926,6 +1128,7 @@ impl RequestForwarder {
|
||||
// 新增 headers
|
||||
"x-initiator",
|
||||
"x-interaction-type",
|
||||
"x-interaction-id",
|
||||
"x-vscode-user-agent-library-version",
|
||||
"x-request-id",
|
||||
"x-agent-task-id",
|
||||
@@ -1155,12 +1358,8 @@ impl RequestForwarder {
|
||||
self.non_streaming_timeout
|
||||
};
|
||||
|
||||
// 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let upstream_proxy_url: Option<String> = proxy_config
|
||||
.filter(|c| c.enabled)
|
||||
.and_then(super::http_client::build_proxy_url_from_config)
|
||||
.or_else(super::http_client::get_current_proxy_url);
|
||||
// 获取全局代理 URL
|
||||
let upstream_proxy_url: Option<String> = super::http_client::get_current_proxy_url();
|
||||
|
||||
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
|
||||
let is_socks_proxy = upstream_proxy_url
|
||||
@@ -1176,7 +1375,7 @@ impl RequestForwarder {
|
||||
let response = if is_socks_proxy {
|
||||
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
|
||||
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
|
||||
let client = super::http_client::get_for_provider(proxy_config);
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url);
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, OptimizerConfig, RectifierConfig},
|
||||
types::{AppProxyConfig, CopilotOptimizerConfig, OptimizerConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -61,6 +61,8 @@ pub struct RequestContext {
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
pub copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -96,6 +98,7 @@ impl RequestContext {
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();
|
||||
let copilot_optimizer_config = state.db.get_copilot_optimizer_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
@@ -160,6 +163,7 @@ impl RequestContext {
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +225,7 @@ impl RequestContext {
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
self.copilot_optimizer_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let request_id = usage.dedup_request_id();
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
request_id,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||
|
||||
use crate::provider::ProviderProxyConfig;
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::Client;
|
||||
use std::env;
|
||||
@@ -334,103 +333,6 @@ pub fn mask_url(url: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据供应商单独代理配置构建代理 URL
|
||||
///
|
||||
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||
pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||
let host = config.proxy_host.as_deref()?;
|
||||
let port = config.proxy_port?;
|
||||
|
||||
// 构建带认证的代理 URL
|
||||
if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
|
||||
if !username.is_empty() && !password.is_empty() {
|
||||
return Some(format!(
|
||||
"{proxy_type}://{username}:{password}@{host}:{port}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!("{proxy_type}://{host}:{port}"))
|
||||
}
|
||||
|
||||
/// 根据供应商单独代理配置构建 HTTP 客户端
|
||||
///
|
||||
/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
|
||||
/// 否则返回 None,调用方应使用全局客户端。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_config` - 供应商的代理配置
|
||||
///
|
||||
/// # Returns
|
||||
/// 如果配置有效则返回 Some(Client),否则返回 None
|
||||
pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {
|
||||
let config = proxy_config.filter(|c| c.enabled)?;
|
||||
|
||||
let proxy_url = build_proxy_url_from_config(config)?;
|
||||
|
||||
log::debug!(
|
||||
"[ProviderProxy] Building client with proxy: {}",
|
||||
mask_url(&proxy_url)
|
||||
);
|
||||
|
||||
// 构建带代理的客户端
|
||||
let proxy = match reqwest::Proxy::all(&proxy_url) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[ProviderProxy] Failed to create proxy from '{}': {}",
|
||||
mask_url(&proxy_url),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
{
|
||||
Ok(client) => {
|
||||
log::info!(
|
||||
"[ProviderProxy] Client built with proxy: {}",
|
||||
mask_url(&proxy_url)
|
||||
);
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[ProviderProxy] Failed to build client: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取供应商专用的 HTTP 客户端
|
||||
///
|
||||
/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_config` - 供应商的代理配置
|
||||
///
|
||||
/// # Returns
|
||||
/// 返回适合该供应商的 HTTP 客户端
|
||||
pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
|
||||
// 优先使用供应商单独代理
|
||||
if let Some(client) = build_client_for_provider(proxy_config) {
|
||||
return client;
|
||||
}
|
||||
|
||||
// 回退到全局客户端
|
||||
get()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub mod copilot_optimizer;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
pub(crate) mod failover_switch;
|
||||
|
||||
@@ -119,6 +119,15 @@ pub enum AuthStrategy {
|
||||
///
|
||||
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
|
||||
GitHubCopilot,
|
||||
|
||||
/// Codex OAuth 认证方式(ChatGPT Plus/Pro)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <access_token>`
|
||||
/// - Header: `ChatGPT-Account-Id: <account_id>` (来自 forwarder 注入)
|
||||
/// - Header: `originator: cc-switch`
|
||||
///
|
||||
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -234,6 +243,7 @@ mod tests {
|
||||
AuthStrategy::Google,
|
||||
AuthStrategy::GoogleOAuth,
|
||||
AuthStrategy::GitHubCopilot,
|
||||
AuthStrategy::CodexOAuth,
|
||||
];
|
||||
|
||||
for (i, s1) in strategies.iter().enumerate() {
|
||||
|
||||
@@ -22,6 +22,13 @@ use crate::proxy::error::ProxyError;
|
||||
/// 供 handler/forwarder 外部使用的公开函数。
|
||||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return "openai_responses";
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
@@ -74,17 +81,64 @@ pub fn transform_claude_request_for_api_format(
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
let cache_key = provider
|
||||
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
|
||||
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
|
||||
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|u| u.contains("githubcopilot.com"));
|
||||
let session_cache_key: Option<String> = if is_copilot {
|
||||
let metadata = body.get("metadata");
|
||||
// Session 提取优先级(与 forwarder 和 session.rs 统一):
|
||||
// 1. metadata.user_id 中的 _session_ 后缀
|
||||
// 2. metadata.session_id(直接字段)
|
||||
metadata
|
||||
.and_then(|m| m.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(super::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())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cache_key = session_cache_key
|
||||
.as_deref()
|
||||
.or_else(|| {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
})
|
||||
.unwrap_or(&provider.id);
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||||
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
|
||||
let is_codex_oauth = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
super::transform_responses::anthropic_to_responses(
|
||||
body,
|
||||
Some(cache_key),
|
||||
is_codex_oauth,
|
||||
)
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
@@ -101,10 +155,16 @@ impl ClaudeAdapter {
|
||||
///
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||||
/// - CodexOAuth: meta.provider_type 为 codex_oauth
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 Codex OAuth (ChatGPT Plus/Pro)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
|
||||
// 检测 GitHub Copilot
|
||||
if self.is_github_copilot(provider) {
|
||||
return ProviderType::GitHubCopilot;
|
||||
@@ -123,6 +183,16 @@ impl ClaudeAdapter {
|
||||
ProviderType::Claude
|
||||
}
|
||||
|
||||
/// 检测是否为 Codex OAuth 供应商(ChatGPT Plus/Pro 反代)
|
||||
fn is_codex_oauth(&self, provider: &Provider) -> bool {
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测是否为 GitHub Copilot 供应商
|
||||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||||
// 方式1: 检查 meta.provider_type
|
||||
@@ -254,6 +324,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return Ok("https://chatgpt.com/backend-api/codex".to_string());
|
||||
}
|
||||
|
||||
// 1. 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
@@ -304,6 +379,15 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
));
|
||||
}
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro) 同样使用占位符
|
||||
// 实际的 access_token 由 CodexOAuthManager 动态提供
|
||||
if provider_type == ProviderType::CodexOAuth {
|
||||
return Some(AuthInfo::new(
|
||||
"codex_oauth_placeholder".to_string(),
|
||||
AuthStrategy::CodexOAuth,
|
||||
));
|
||||
}
|
||||
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
@@ -315,6 +399,12 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
// Codex OAuth: 所有请求统一走 /responses 端点
|
||||
if base_url == "https://chatgpt.com/backend-api/codex" {
|
||||
let _ = endpoint; // 忽略原始 endpoint
|
||||
return "https://chatgpt.com/backend-api/codex/responses".to_string();
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||
@@ -347,6 +437,20 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
AuthStrategy::CodexOAuth => {
|
||||
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
|
||||
// ChatGPT-Account-Id 由 forwarder 注入额外 header
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static("cc-switch"),
|
||||
),
|
||||
]
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -388,6 +492,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderName::from_static("x-interaction-type"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
// x-interaction-id 由 forwarder 按需注入(仅在有 session 时)
|
||||
(
|
||||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||||
HeaderValue::from_static("electron-fetch"),
|
||||
@@ -412,6 +517,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Codex OAuth 总是需要格式转换 (Anthropic → OpenAI Responses API)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ mod adapter;
|
||||
mod auth;
|
||||
mod claude;
|
||||
mod codex;
|
||||
pub mod codex_oauth_auth;
|
||||
pub mod copilot_auth;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
@@ -58,6 +59,8 @@ pub enum ProviderType {
|
||||
OpenRouter,
|
||||
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
|
||||
GitHubCopilot,
|
||||
/// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换)
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
@@ -70,6 +73,7 @@ impl ProviderType {
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::GitHubCopilot => true,
|
||||
ProviderType::CodexOAuth => true,
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
@@ -86,6 +90,7 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +106,9 @@ impl ProviderType {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 base_url 是否为 GitHub Copilot
|
||||
@@ -175,6 +183,7 @@ impl ProviderType {
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
ProviderType::GitHubCopilot => "github_copilot",
|
||||
ProviderType::CodexOAuth => "codex_oauth",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +208,7 @@ impl std::str::FromStr for ProviderType {
|
||||
"github_copilot" | "github-copilot" | "githubcopilot" => {
|
||||
Ok(ProviderType::GitHubCopilot)
|
||||
}
|
||||
"codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth),
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -228,7 +238,8 @@ pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn Pr
|
||||
ProviderType::Claude
|
||||
| ProviderType::ClaudeAuth
|
||||
| ProviderType::OpenRouter
|
||||
| ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),
|
||||
| ProviderType::GitHubCopilot
|
||||
| ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()),
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
|
||||
@@ -85,14 +85,23 @@ struct ToolBlockState {
|
||||
name: String,
|
||||
started: bool,
|
||||
pending_args: String,
|
||||
/// 连续空白字符计数 — 用于检测 Copilot 无限换行 bug
|
||||
/// 当 function call 参数中出现连续 20+ 空白字符时,强制终止流
|
||||
consecutive_whitespace: usize,
|
||||
/// 是否已因无限空白 bug 被中止
|
||||
aborted: bool,
|
||||
}
|
||||
|
||||
/// 无限空白 bug 的连续空白字符阈值
|
||||
const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut next_content_index: u32 = 0;
|
||||
@@ -107,8 +116,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let line = buffer[..pos].to_string();
|
||||
@@ -297,9 +305,16 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
name: String::new(),
|
||||
started: false,
|
||||
pending_args: String::new(),
|
||||
consecutive_whitespace: 0,
|
||||
aborted: false,
|
||||
}
|
||||
});
|
||||
|
||||
// 如果此 tool call 已被中止(无限空白 bug),跳过后续处理
|
||||
if state.aborted {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(id) = &tool_call.id {
|
||||
state.id = id.clone();
|
||||
}
|
||||
@@ -328,7 +343,22 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
.as_ref()
|
||||
.and_then(|f| f.arguments.clone());
|
||||
let immediate_delta = if let Some(args) = args_delta {
|
||||
if state.started {
|
||||
// 无限空白 bug 检测:跟踪连续空白字符
|
||||
for ch in args.chars() {
|
||||
if ch.is_whitespace() {
|
||||
state.consecutive_whitespace += 1;
|
||||
} else {
|
||||
state.consecutive_whitespace = 0;
|
||||
}
|
||||
}
|
||||
if state.consecutive_whitespace >= INFINITE_WHITESPACE_THRESHOLD {
|
||||
log::warn!(
|
||||
"[Copilot] 检测到无限空白 bug (tool: {}), 中止此 tool call 流",
|
||||
state.name
|
||||
);
|
||||
state.aborted = true;
|
||||
None
|
||||
} else if state.started {
|
||||
Some(args)
|
||||
} else {
|
||||
state.pending_args.push_str(&args);
|
||||
@@ -750,4 +780,45 @@ mod tests {
|
||||
assert!(deltas.contains(&"{\"a\":"));
|
||||
assert!(deltas.contains(&"1}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_chinese_split_across_chunks_no_replacement_chars() {
|
||||
// "你好" split across two TCP chunks inside a streaming text delta.
|
||||
// Before the fix, from_utf8_lossy would produce U+FFFD for each half.
|
||||
let full = concat!(
|
||||
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
let bytes = full.as_bytes();
|
||||
|
||||
// Find "你" in the byte stream and split inside it
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 1; // split after first byte of "你"
|
||||
|
||||
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
|
||||
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
|
||||
|
||||
let upstream = stream::iter(vec![
|
||||
Ok::<_, std::io::Error>(chunk1),
|
||||
Ok::<_, std::io::Error>(chunk2),
|
||||
]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
// Must contain the original Chinese characters, not replacement chars
|
||||
assert!(
|
||||
merged.contains("你好"),
|
||||
"expected '你好' in output, got replacement chars (U+FFFD)"
|
||||
);
|
||||
assert!(
|
||||
!merged.contains('\u{FFFD}'),
|
||||
"output must not contain U+FFFD replacement characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut current_model: Option<String> = None;
|
||||
let mut has_sent_message_start = false;
|
||||
@@ -118,8 +119,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// SSE 事件由 \n\n 分隔
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
@@ -1029,4 +1029,45 @@ mod tests {
|
||||
assert_eq!(text_stops, 1);
|
||||
assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_responses_chinese_split_across_chunks_no_replacement_chars() {
|
||||
// Chinese text delta split across two TCP chunks.
|
||||
let full = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cn\",\"model\":\"gpt-4o\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好世界\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":4}}}\n\n"
|
||||
);
|
||||
let bytes = full.as_bytes();
|
||||
|
||||
// Find "你" and split inside it
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 2; // split after second byte of "你"
|
||||
|
||||
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
|
||||
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
|
||||
|
||||
let upstream = stream::iter(vec![
|
||||
Ok::<_, std::io::Error>(chunk1),
|
||||
Ok::<_, std::io::Error>(chunk2),
|
||||
]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(
|
||||
merged.contains("你好世界"),
|
||||
"expected '你好世界' in output, got replacement chars (U+FFFD)"
|
||||
);
|
||||
assert!(
|
||||
!merged.contains('\u{FFFD}'),
|
||||
"output must not contain U+FFFD replacement characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn supports_reasoning_effort(model: &str) -> bool {
|
||||
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
|
||||
/// (supported by mainstream GPT models). Unknown values are ignored.
|
||||
/// 2. Fallback: `thinking.type` + `budget_tokens`:
|
||||
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
|
||||
/// - `adaptive` → `xhigh` (adaptive = maximum reasoning effort)
|
||||
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 000–15 999) / `high` (≥16 000)
|
||||
/// - `enabled` without budget → `high` (conservative default)
|
||||
/// - `disabled` / absent → `None`
|
||||
@@ -57,7 +57,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
// --- Priority 2: thinking.type + budget_tokens fallback ---
|
||||
let thinking = body.get("thinking")?;
|
||||
match thinking.get("type").and_then(|t| t.as_str()) {
|
||||
Some("adaptive") => Some("high"),
|
||||
Some("adaptive") => Some("xhigh"),
|
||||
Some("enabled") => {
|
||||
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
|
||||
match budget {
|
||||
@@ -71,10 +71,8 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
/// Anthropic 请求 → OpenAI Chat Completions 请求
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -113,6 +111,7 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
}
|
||||
}
|
||||
|
||||
normalize_openai_system_messages(&mut messages);
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数 — o-series 模型需要 max_completion_tokens
|
||||
@@ -174,12 +173,79 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
let system_count = messages
|
||||
.iter()
|
||||
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system"))
|
||||
.count();
|
||||
|
||||
if system_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
if system_count == 1 {
|
||||
if let Some(index) = messages.iter().position(|message| {
|
||||
message.get("role").and_then(|value| value.as_str()) == Some("system")
|
||||
}) {
|
||||
if index > 0 {
|
||||
let message = messages.remove(index);
|
||||
messages.insert(0, message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut inherited_cache_control: Option<Value> = None;
|
||||
let mut cache_control_conflict = false;
|
||||
let mut saw_cache_control = false;
|
||||
let mut saw_missing_cache_control = false;
|
||||
messages.retain(|message| {
|
||||
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match message.get("content") {
|
||||
Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()),
|
||||
Some(Value::Array(content_parts)) => {
|
||||
let text = content_parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !text.is_empty() {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(cache_control) = message.get("cache_control") {
|
||||
saw_cache_control = true;
|
||||
match &inherited_cache_control {
|
||||
None => inherited_cache_control = Some(cache_control.clone()),
|
||||
Some(existing) if existing == cache_control => {}
|
||||
Some(_) => cache_control_conflict = true,
|
||||
}
|
||||
} else {
|
||||
saw_missing_cache_control = true;
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
let mut merged = json!({"role": "system", "content": parts.join("\n")});
|
||||
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
|
||||
if let Some(cache_control) = inherited_cache_control {
|
||||
merged["cache_control"] = cache_control;
|
||||
}
|
||||
}
|
||||
messages.insert(0, merged);
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
@@ -517,7 +583,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "claude-3-opus");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
@@ -533,7 +599,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
@@ -555,11 +621,76 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let input = json!({
|
||||
@@ -574,7 +705,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
@@ -594,7 +725,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
@@ -666,31 +797,19 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_cache_key() {
|
||||
fn test_anthropic_to_openai_does_not_inject_prompt_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "provider-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_no_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
@@ -716,7 +835,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
// System message cache_control preserved
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
// Text block cache_control preserved
|
||||
@@ -942,9 +1061,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive_maps_high() {
|
||||
fn test_thinking_adaptive_maps_xhigh() {
|
||||
let body = json!({"thinking": {"type": "adaptive"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -970,7 +1089,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
@@ -983,7 +1102,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "medium");
|
||||
}
|
||||
|
||||
@@ -996,7 +1115,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -1009,7 +1128,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
@@ -1022,8 +1141,8 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1034,7 +1153,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
@@ -1047,7 +1166,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(
|
||||
result.get("max_tokens").is_none(),
|
||||
"{model} should not have max_tokens"
|
||||
@@ -1067,7 +1186,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
@@ -14,7 +14,14 @@ use serde_json::{json, Value};
|
||||
/// Anthropic 请求 → OpenAI Responses 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
|
||||
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
|
||||
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
|
||||
pub fn anthropic_to_responses(
|
||||
body: Value,
|
||||
cache_key: Option<&str>,
|
||||
is_codex_oauth: bool,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -103,6 +110,59 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 特殊协议约束:
|
||||
// 整体依据:OpenAI 官方 codex-rs 的 `ResponsesApiRequest` 结构体
|
||||
// (codex-rs/codex-api/src/common.rs) 是 ChatGPT 反代后端的协议契约。
|
||||
// 任何不在该结构体里的字段都可能被 ChatGPT 后端以
|
||||
// "Unsupported parameter: ..." 400 拒绝;任何在结构体里的必填字段
|
||||
// 都需要在请求体里出现。
|
||||
//
|
||||
// 字段处理:
|
||||
// - store: 必须显式为 false(ChatGPT 消费级后端不允许服务端持久化)
|
||||
// - include: 必须包含 "reasoning.encrypted_content",
|
||||
// 否则多轮 reasoning 中间态会丢失(无服务端状态 + 无加密回传 = 上下文断链)
|
||||
// - max_output_tokens / temperature / top_p: 必须删除
|
||||
// (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们)
|
||||
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
|
||||
// (cc-switch 的 transform 当前是"条件写入",可能产生缺失)
|
||||
// - stream: 必须永远 true(codex-rs 硬编码 true,且 cc-switch 的
|
||||
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false)
|
||||
if is_codex_oauth {
|
||||
result["store"] = json!(false);
|
||||
|
||||
const REASONING_MARKER: &str = "reasoning.encrypted_content";
|
||||
let mut includes: Vec<Value> = body
|
||||
.get("include")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(REASONING_MARKER))
|
||||
{
|
||||
includes.push(json!(REASONING_MARKER));
|
||||
}
|
||||
result["include"] = json!(includes);
|
||||
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
// —— 删除 ChatGPT 反代不接受的字段 ——
|
||||
obj.remove("max_output_tokens");
|
||||
obj.remove("temperature");
|
||||
obj.remove("top_p");
|
||||
|
||||
// —— 兜底必填字段(or_insert:客户端送了什么就保留,否则注入默认值)——
|
||||
obj.entry("instructions".to_string()).or_insert(json!(""));
|
||||
obj.entry("tools".to_string()).or_insert(json!([]));
|
||||
obj.entry("parallel_tool_calls".to_string())
|
||||
.or_insert(json!(false));
|
||||
|
||||
// —— 强制覆盖 stream = true ——
|
||||
// 即便客户端误传 stream:false 也要覆盖,因为 codex-rs 永远 true,
|
||||
// 且 cc-switch SSE 解析层只支持流式响应。
|
||||
obj.insert("stream".to_string(), json!(true));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -468,7 +528,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
assert_eq!(result["max_output_tokens"], 1024);
|
||||
assert_eq!(result["input"][0]["role"], "user");
|
||||
@@ -487,7 +547,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
// system should not appear in input
|
||||
assert_eq!(result["input"].as_array().unwrap().len(), 1);
|
||||
@@ -505,7 +565,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
|
||||
}
|
||||
|
||||
@@ -522,7 +582,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["name"], "get_weather");
|
||||
assert!(result["tools"][0].get("parameters").is_some());
|
||||
@@ -539,7 +599,7 @@ mod tests {
|
||||
"tool_choice": {"type": "any"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tool_choice"], "required");
|
||||
}
|
||||
|
||||
@@ -552,7 +612,7 @@ mod tests {
|
||||
"tool_choice": {"type": "tool", "name": "get_weather"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["name"], "get_weather");
|
||||
}
|
||||
@@ -571,7 +631,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: assistant message (text) + function_call item
|
||||
@@ -601,7 +661,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: function_call_output item (lifted)
|
||||
@@ -625,7 +685,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// thinking should be discarded, only text remains
|
||||
@@ -648,7 +708,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let content = result["input"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
@@ -806,7 +866,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["model"], "o3-mini");
|
||||
}
|
||||
|
||||
@@ -818,7 +878,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id")).unwrap();
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "my-provider-id");
|
||||
}
|
||||
|
||||
@@ -836,7 +896,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result["tools"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
@@ -853,7 +913,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result["input"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
@@ -915,7 +975,7 @@ mod tests {
|
||||
"max_tokens": 4096,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["max_output_tokens"], 4096);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
@@ -929,7 +989,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -943,7 +1003,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -956,7 +1016,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -969,7 +1029,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "medium");
|
||||
}
|
||||
|
||||
@@ -982,12 +1042,12 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_thinking_adaptive_sets_reasoning_high() {
|
||||
fn test_responses_thinking_adaptive_sets_reasoning_xhigh() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
@@ -995,8 +1055,8 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1008,7 +1068,271 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
// ==================== Codex OAuth (ChatGPT 反代) 协议约束 ====================
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_sets_store_and_include() {
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
// store 必须显式为 false(ChatGPT 后端拒绝 true)
|
||||
assert_eq!(result["store"], json!(false));
|
||||
|
||||
// include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning)
|
||||
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_non_codex_omits_store_and_include() {
|
||||
// 回归护栏:is_codex_oauth=false 时,行为必须与今日字节级一致
|
||||
// —— 不写 store、不写 include,OpenRouter / Azure / OpenAI 付费 API 路径不受影响
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert!(result.get("store").is_none());
|
||||
assert!(result.get("include").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_preserves_existing_include() {
|
||||
// 客户端预置了 include:union 保留原有项 + 添加 marker,不重复
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"include": ["something.else", "reasoning.encrypted_content"]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let includes = result["include"]
|
||||
.as_array()
|
||||
.expect("include should be array");
|
||||
|
||||
// 原有项必须保留
|
||||
assert!(includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some("something.else")));
|
||||
// marker 必须存在
|
||||
assert!(includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some("reasoning.encrypted_content")));
|
||||
// 不重复:marker 只出现一次
|
||||
let marker_count = includes
|
||||
.iter()
|
||||
.filter(|v| v.as_str() == Some("reasoning.encrypted_content"))
|
||||
.count();
|
||||
assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
|
||||
// ChatGPT Plus/Pro 反代不接受 max_output_tokens(OpenAI 官方 codex-rs 的
|
||||
// ResponsesApiRequest 结构体里也没有这个字段),必须删除,否则服务端 400:
|
||||
// "Unsupported parameter: max_output_tokens"
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("max_output_tokens").is_none(),
|
||||
"Codex OAuth 路径必须删除 max_output_tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_non_codex_keeps_max_output_tokens() {
|
||||
// 回归护栏:非 Codex OAuth 路径必须保留 max_output_tokens
|
||||
// —— OpenAI 付费 Responses API / Azure 等仍然依赖这个字段
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert_eq!(result["max_output_tokens"], json!(1024));
|
||||
}
|
||||
|
||||
// ==================== 第二轮:P0 + P1 字段对齐 ====================
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_strips_temperature() {
|
||||
// P0: ChatGPT 反代不接受 temperature
|
||||
// 依据:OpenAI 官方 codex-rs 的 ResponsesApiRequest 结构体根本没有这个字段
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("temperature").is_none(),
|
||||
"Codex OAuth 路径必须删除 temperature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_strips_top_p() {
|
||||
// P0: ChatGPT 反代不接受 top_p
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"top_p": 0.9,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("top_p").is_none(),
|
||||
"Codex OAuth 路径必须删除 top_p"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_defaults_required_fields_when_absent() {
|
||||
// P1: 极简输入(无 system / 无 tools / 无 stream),断言四个必填字段都被注入默认值
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
json!(""),
|
||||
"instructions 缺失时应兜底为空字符串"
|
||||
);
|
||||
assert_eq!(result["tools"], json!([]), "tools 缺失时应兜底为空数组");
|
||||
assert_eq!(
|
||||
result["parallel_tool_calls"],
|
||||
json!(false),
|
||||
"parallel_tool_calls 应兜底为 false"
|
||||
);
|
||||
assert_eq!(result["stream"], json!(true), "stream 应被强制设为 true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_preserves_existing_instructions_and_tools() {
|
||||
// P1: 客户端送了 system 和 tools,应保留原值,不被默认值覆盖
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant",
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}}
|
||||
}
|
||||
}],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
json!("You are a helpful assistant"),
|
||||
"client 已送的 instructions 必须保留"
|
||||
);
|
||||
|
||||
let tools = result["tools"].as_array().expect("tools 应为数组");
|
||||
assert_eq!(tools.len(), 1, "client 已送的 tools 必须保留");
|
||||
assert_eq!(tools[0]["name"], json!("get_weather"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_forces_stream_true_even_when_client_sends_false() {
|
||||
// 即使客户端误传 stream:false,也要强制覆盖为 true
|
||||
// 依据:cc-switch SSE 解析层只支持流式响应
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"stream": false,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["stream"],
|
||||
json!(true),
|
||||
"Codex OAuth 路径下 stream 必须强制为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_codex_keeps_temperature_and_top_p() {
|
||||
// 回归护栏:非 Codex OAuth 路径必须保留 temperature/top_p
|
||||
// —— 防止 P0 删除逻辑误扩散到 OpenRouter / Azure / 付费 OpenAI 路径
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert_eq!(result["temperature"], json!(0.7));
|
||||
assert_eq!(result["top_p"], json!(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_codex_does_not_inject_default_required_fields() {
|
||||
// 回归护栏:非 Codex OAuth 路径不应被 P1 默认值污染
|
||||
// —— OpenRouter / Azure / 付费 OpenAI 等保持原有"条件写入"语义
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
"非 Codex OAuth 路径不应注入 parallel_tool_calls"
|
||||
);
|
||||
assert!(
|
||||
result.get("stream").is_none(),
|
||||
"非 Codex OAuth 路径不应注入 stream"
|
||||
);
|
||||
// instructions 和 tools 因为客户端没送,所以不应出现
|
||||
assert!(
|
||||
result.get("instructions").is_none(),
|
||||
"非 Codex OAuth 路径下 instructions 在客户端未送时不应被注入"
|
||||
);
|
||||
assert!(
|
||||
result.get("tools").is_none(),
|
||||
"非 Codex OAuth 路径下 tools 在客户端未送时不应被注入"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ impl StreamHandler {
|
||||
async_stream::stream! {
|
||||
let mut _last_activity = Instant::now();
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
@@ -82,8 +83,7 @@ impl StreamHandler {
|
||||
_last_activity = Instant::now();
|
||||
|
||||
// 解析 SSE 事件
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
|
||||
@@ -528,7 +528,7 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let request_id = usage.dedup_request_id();
|
||||
|
||||
log::debug!(
|
||||
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
|
||||
@@ -568,6 +568,7 @@ pub fn create_logged_passthrough_stream(
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut collector = usage_collector;
|
||||
let mut is_first_chunk = true;
|
||||
|
||||
@@ -619,8 +620,7 @@ pub fn create_logged_passthrough_stream(
|
||||
);
|
||||
}
|
||||
is_first_chunk = false;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 尝试解析并记录完整的 SSE 事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
@@ -784,6 +784,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
@@ -843,6 +844,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
|
||||
@@ -23,7 +23,6 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
/// 代理服务器状态(共享)
|
||||
#[derive(Clone)]
|
||||
@@ -275,11 +274,6 @@ impl ProxyServer {
|
||||
}
|
||||
|
||||
fn build_router(&self) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
// 健康检查
|
||||
.route("/health", get(handlers::health_check))
|
||||
@@ -328,7 +322,6 @@ impl ProxyServer {
|
||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||
// 提高默认请求体大小限制(避免 413 Payload Too Large)
|
||||
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
|
||||
.layer(cors)
|
||||
.with_state(self.state.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ fn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
/// 从 user_id 解析 session_id
|
||||
///
|
||||
/// 格式: `user_identifier_session_actual_session_id`
|
||||
fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
pub(super) fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
// 查找 "_session_" 分隔符
|
||||
if let Some(pos) = user_id.find("_session_") {
|
||||
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
|
||||
|
||||
+274
-1
@@ -4,9 +4,71 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str>
|
||||
.or_else(|| line.strip_prefix(&format!("{field}:")))
|
||||
}
|
||||
|
||||
/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte
|
||||
/// characters that are split across chunk boundaries.
|
||||
///
|
||||
/// `remainder` accumulates trailing bytes from the previous chunk that form an
|
||||
/// incomplete UTF-8 sequence (at most 3 bytes under normal operation). On each
|
||||
/// call the remainder is prepended to `new_bytes`, the longest valid UTF-8
|
||||
/// prefix is appended to `buffer`, and any trailing incomplete bytes are saved
|
||||
/// back into `remainder` for the next call.
|
||||
///
|
||||
/// A defensive guard discards `remainder` via lossy conversion if it ever
|
||||
/// exceeds 3 bytes, which cannot happen with well-formed UTF-8 streams.
|
||||
pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec<u8>, new_bytes: &[u8]) {
|
||||
// Build the byte slice to decode: prepend any leftover bytes from previous chunk.
|
||||
let (owned, bytes): (Option<Vec<u8>>, &[u8]) = if remainder.is_empty() {
|
||||
(None, new_bytes)
|
||||
} else {
|
||||
// Defensive guard: remainder should never exceed 3 bytes (max incomplete
|
||||
// UTF-8 sequence is 3 bytes: a 4-byte char missing its last byte). If it
|
||||
// does, the stream is producing genuinely invalid bytes; flush them lossy
|
||||
// and start fresh.
|
||||
if remainder.len() > 3 {
|
||||
buffer.push_str(&String::from_utf8_lossy(remainder));
|
||||
remainder.clear();
|
||||
(None, new_bytes)
|
||||
} else {
|
||||
let mut combined = std::mem::take(remainder);
|
||||
combined.extend_from_slice(new_bytes);
|
||||
(Some(combined), &[])
|
||||
}
|
||||
};
|
||||
let input = owned.as_deref().unwrap_or(bytes);
|
||||
|
||||
// Decode loop: consume all valid UTF-8 and any genuinely invalid bytes,
|
||||
// only leaving a trailing incomplete sequence in remainder.
|
||||
let mut pos = 0;
|
||||
loop {
|
||||
match std::str::from_utf8(&input[pos..]) {
|
||||
Ok(s) => {
|
||||
buffer.push_str(s);
|
||||
// Everything consumed – remainder stays empty.
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let valid_up_to = pos + e.valid_up_to();
|
||||
buffer.push_str(
|
||||
// Safety: from_utf8 guarantees [pos..valid_up_to] is valid UTF-8.
|
||||
std::str::from_utf8(&input[pos..valid_up_to]).unwrap(),
|
||||
);
|
||||
if let Some(invalid_len) = e.error_len() {
|
||||
// Genuinely invalid byte(s) – emit U+FFFD and continue.
|
||||
buffer.push('\u{FFFD}');
|
||||
pos = valid_up_to + invalid_len;
|
||||
} else {
|
||||
// Incomplete trailing sequence – stash for next chunk.
|
||||
*remainder = input[valid_up_to..].to_vec();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_sse_field;
|
||||
use super::{append_utf8_safe, strip_sse_field};
|
||||
|
||||
#[test]
|
||||
fn strip_sse_field_accepts_optional_space() {
|
||||
@@ -28,4 +90,215 @@ mod tests {
|
||||
);
|
||||
assert_eq!(strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// append_utf8_safe tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ascii_passthrough() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hello world");
|
||||
assert_eq!(buf, "hello world");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_multibyte_in_single_chunk() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
append_utf8_safe(&mut buf, &mut rem, "你好世界".as_bytes());
|
||||
assert_eq!(buf, "你好世界");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_multibyte_across_two_chunks() {
|
||||
// "你" = E4 BD A0 (3 bytes)
|
||||
let bytes = "你".as_bytes();
|
||||
assert_eq!(bytes.len(), 3);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Chunk 1: first 2 bytes (incomplete)
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..2]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 2);
|
||||
|
||||
// Chunk 2: last byte completes the character
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[2..]);
|
||||
assert_eq!(buf, "你");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_four_byte_char_across_chunks() {
|
||||
// 😀 = F0 9F 98 80 (4 bytes)
|
||||
let bytes = "😀".as_bytes();
|
||||
assert_eq!(bytes.len(), 4);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Send 1 byte at a time
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..1]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[1..2]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 2);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[2..3]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 3);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[3..]);
|
||||
assert_eq!(buf, "😀");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_ascii_and_split_multibyte() {
|
||||
// "hi你" = 68 69 E4 BD A0
|
||||
let all = "hi你".as_bytes();
|
||||
assert_eq!(all.len(), 5);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Chunk 1: "hi" + first byte of "你"
|
||||
append_utf8_safe(&mut buf, &mut rem, &all[..3]);
|
||||
assert_eq!(buf, "hi");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Chunk 2: remaining 2 bytes of "你"
|
||||
append_utf8_safe(&mut buf, &mut rem, &all[3..]);
|
||||
assert_eq!(buf, "hi你");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_split_characters_in_sequence() {
|
||||
let text = "你好";
|
||||
let bytes = text.as_bytes(); // E4 BD A0 E5 A5 BD
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Split in the middle: first char complete + 1 byte of second
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..4]);
|
||||
assert_eq!(buf, "你");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Remaining 2 bytes complete second char
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[4..]);
|
||||
assert_eq!(buf, "你好");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_chunks_are_harmless() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"");
|
||||
assert_eq!(buf, "");
|
||||
assert!(rem.is_empty());
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"ok");
|
||||
assert_eq!(buf, "ok");
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"");
|
||||
assert_eq!(buf, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_json_with_chinese_split_at_boundary() {
|
||||
// Simulates an SSE data line with Chinese content split across chunks
|
||||
let json_line = "data: {\"text\":\"你好\"}\n\n";
|
||||
let bytes = json_line.as_bytes();
|
||||
|
||||
// Find where "你" starts in the byte stream and split there
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 1; // split inside "你"
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..split_point]);
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[split_point..]);
|
||||
|
||||
assert_eq!(buf, json_line);
|
||||
assert!(rem.is_empty());
|
||||
|
||||
// Verify the buffer can be parsed as SSE with valid JSON
|
||||
let data = strip_sse_field(buf.lines().next().unwrap(), "data").unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(data).unwrap();
|
||||
assert_eq!(parsed["text"], "你好");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bytes_flushed_immediately_not_accumulated() {
|
||||
// 0xFF is never valid in UTF-8 – it should be replaced immediately,
|
||||
// not stashed in remainder.
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// "hi" + invalid byte + "ok"
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hi\xFFok");
|
||||
assert!(
|
||||
rem.is_empty(),
|
||||
"remainder should be empty after invalid byte"
|
||||
);
|
||||
assert!(buf.contains("hi"), "valid prefix must be present");
|
||||
assert!(buf.contains("ok"), "valid suffix must be present");
|
||||
assert!(buf.contains('\u{FFFD}'), "invalid byte must produce U+FFFD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_byte_in_slow_path_flushed_immediately() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Prime remainder with an incomplete sequence (first byte of "你")
|
||||
append_utf8_safe(&mut buf, &mut rem, &"你".as_bytes()[..1]);
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Next chunk starts with an invalid byte – the stale remainder and the
|
||||
// invalid byte should both be flushed, not accumulated.
|
||||
append_utf8_safe(&mut buf, &mut rem, b"\xFFworld");
|
||||
assert!(rem.is_empty(), "remainder should be empty");
|
||||
assert!(
|
||||
buf.contains("world"),
|
||||
"valid data after invalid byte must appear"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defensive_guard_flushes_oversized_remainder() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Manually inject 4 invalid bytes into remainder to trigger the >3 guard.
|
||||
// This can't happen with well-formed UTF-8, but tests the safety net.
|
||||
rem.extend_from_slice(b"\x80\x80\x80\x80");
|
||||
assert_eq!(rem.len(), 4);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hello");
|
||||
// The 4 invalid bytes should have been flushed lossy, then "hello" decoded.
|
||||
assert!(rem.is_empty(), "remainder must be empty after guard flush");
|
||||
assert!(
|
||||
buf.contains("hello"),
|
||||
"valid data after guard flush must appear"
|
||||
);
|
||||
// The 4 invalid bytes each produce a U+FFFD
|
||||
let replacement_count = buf.chars().filter(|&c| c == '\u{FFFD}').count();
|
||||
assert_eq!(
|
||||
replacement_count, 4,
|
||||
"each invalid byte should produce one U+FFFD"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,59 @@ impl Default for OptimizerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copilot 优化器配置
|
||||
///
|
||||
/// 存储在 settings 表中,key = "copilot_optimizer_config"
|
||||
/// 解决 Copilot 代理消耗量异常问题(Issue #1813)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotOptimizerConfig {
|
||||
/// 总开关(默认开启 — 对 Copilot 用户至关重要)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// x-initiator 请求分类(默认开启,P0 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub request_classification: bool,
|
||||
/// Tool result 消息合并(默认开启,P1 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub tool_result_merging: bool,
|
||||
/// Compact 请求识别(默认开启,P2 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub compact_detection: bool,
|
||||
/// 确定性 Request ID(默认开启,P3 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub deterministic_request_id: bool,
|
||||
/// Subagent 检测(默认开启)— 识别 Claude Code 子代理请求,
|
||||
/// 设置 x-initiator=agent + x-interaction-type=conversation-subagent,避免子代理计费
|
||||
#[serde(default = "default_true")]
|
||||
pub subagent_detection: bool,
|
||||
/// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota)
|
||||
#[serde(default = "default_true")]
|
||||
pub warmup_downgrade: bool,
|
||||
/// Warmup 降级使用的模型(默认 "gpt-4o-mini")
|
||||
#[serde(default = "default_warmup_model")]
|
||||
pub warmup_model: String,
|
||||
}
|
||||
|
||||
fn default_warmup_model() -> String {
|
||||
"gpt-5-mini".to_string()
|
||||
}
|
||||
|
||||
impl Default for CopilotOptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_classification: true,
|
||||
tool_result_merging: true,
|
||||
compact_detection: true,
|
||||
deterministic_request_id: true,
|
||||
subagent_detection: true,
|
||||
warmup_downgrade: true,
|
||||
warmup_model: "gpt-4o-mini".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
|
||||
@@ -114,6 +114,7 @@ mod tests {
|
||||
cache_read_tokens: 200,
|
||||
cache_creation_tokens: 100,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
|
||||
@@ -144,6 +145,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
|
||||
@@ -165,6 +167,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
@@ -181,6 +184,7 @@ mod tests {
|
||||
cache_read_tokens: 1,
|
||||
cache_creation_tokens: 1,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
|
||||
|
||||
@@ -73,7 +73,7 @@ impl<'a> UsageLogger<'a> {
|
||||
});
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
@@ -358,6 +358,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
logger.log_with_calculation(
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
|
||||
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
|
||||
|
||||
/// Token 使用量统计
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
@@ -18,6 +21,22 @@ pub struct TokenUsage {
|
||||
pub cache_creation_tokens: u32,
|
||||
/// 从响应中提取的实际模型名称(如果可用)
|
||||
pub model: Option<String>,
|
||||
/// 从响应中提取的消息 ID(用于跨源去重)
|
||||
///
|
||||
/// Claude API: `msg_xxx`,与 session JSONL 中的 `message.id` 一致
|
||||
#[serde(skip)]
|
||||
pub message_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
/// 生成与 session 日志共享的 request_id,用于跨源去重。
|
||||
/// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。
|
||||
pub fn dedup_request_id(&self) -> String {
|
||||
self.message_id
|
||||
.as_ref()
|
||||
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// API 类型
|
||||
@@ -39,6 +58,10 @@ impl TokenUsage {
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let message_id = body
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
||||
@@ -52,6 +75,7 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model,
|
||||
message_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,18 +84,23 @@ impl TokenUsage {
|
||||
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
|
||||
let mut usage = Self::default();
|
||||
let mut model: Option<String> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
match event_type {
|
||||
"message_start" => {
|
||||
// 从 message_start 提取模型名称
|
||||
if model.is_none() {
|
||||
if let Some(message) = event.get("message") {
|
||||
if let Some(message) = event.get("message") {
|
||||
if model.is_none() {
|
||||
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
|
||||
model = Some(m.to_string());
|
||||
}
|
||||
}
|
||||
if message_id.is_none() {
|
||||
if let Some(id) = message.get("id").and_then(|v| v.as_str()) {
|
||||
message_id = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
|
||||
// 从 message_start 获取 input_tokens(原生 Claude API)
|
||||
@@ -137,6 +166,7 @@ impl TokenUsage {
|
||||
|
||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||
usage.model = model;
|
||||
usage.message_id = message_id;
|
||||
Some(usage)
|
||||
} else {
|
||||
None
|
||||
@@ -153,6 +183,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,6 +233,7 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -245,6 +277,7 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,6 +372,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,6 +417,7 @@ impl TokenUsage {
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -433,6 +468,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: total_cache_read,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
Reference in New Issue
Block a user