mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Merge branch 'main' into fix/proxy-hop-by-hop-headers
This commit is contained in:
@@ -253,6 +253,19 @@ pub async fn switch_proxy_provider(
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
// Block official providers during proxy takeover
|
||||
let provider = state
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, &app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
.proxy_service
|
||||
.switch_proxy_target(&app_type, &provider_id)
|
||||
|
||||
@@ -116,15 +116,24 @@ pub async fn stream_check_all_providers(
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
.unwrap_or_else(|e| {
|
||||
let (http_status, message) = match &e {
|
||||
crate::error::AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
StreamCheckService::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message,
|
||||
response_time_ms: None,
|
||||
http_status,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
}
|
||||
});
|
||||
|
||||
let _ = state
|
||||
|
||||
@@ -44,7 +44,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 8;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 9;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -418,6 +418,11 @@ impl Database {
|
||||
Self::migrate_v7_to_v8(conn)?;
|
||||
Self::set_user_version(conn, 8)?;
|
||||
}
|
||||
8 => {
|
||||
log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
|
||||
Self::migrate_v8_to_v9(conn)?;
|
||||
Self::set_user_version(conn, 9)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1144,6 +1149,25 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v8 → v9: 全面补充模型定价(清空 + 重新 seed)
|
||||
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
|
||||
conn.execute("DELETE FROM model_pricing", [])
|
||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||
Self::seed_model_pricing(conn)?;
|
||||
log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -1491,6 +1515,38 @@ impl Database {
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-pro",
|
||||
"Doubao Seed 2.0 Pro",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-code",
|
||||
"Doubao Seed 2.0 Code",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-lite",
|
||||
"Doubao Seed 2.0 Lite",
|
||||
"0.25",
|
||||
"2",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-mini",
|
||||
"Doubao Seed 2.0 Mini",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek 系列
|
||||
(
|
||||
"deepseek-v3.2",
|
||||
@@ -1512,17 +1568,17 @@ impl Database {
|
||||
(
|
||||
"deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
"0.28",
|
||||
"0.42",
|
||||
"0.028",
|
||||
"0.27",
|
||||
"1.10",
|
||||
"0.07",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.28",
|
||||
"0.42",
|
||||
"0.028",
|
||||
"0.55",
|
||||
"2.19",
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
// Kimi (月之暗面)
|
||||
@@ -1543,7 +1599,7 @@ impl Database {
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
|
||||
// MiniMax 系列
|
||||
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
|
||||
(
|
||||
@@ -1555,35 +1611,211 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
|
||||
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
|
||||
(
|
||||
"minimax-m2.5-lightning",
|
||||
"MiniMax M2.5 Lightning",
|
||||
"0.30",
|
||||
"2.40",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"minimax-m2.7",
|
||||
"MiniMax M2.7",
|
||||
"0.30",
|
||||
"1.20",
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
(
|
||||
"minimax-m2.7-highspeed",
|
||||
"MiniMax M2.7 Highspeed",
|
||||
"0.60",
|
||||
"2.40",
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
|
||||
// Mimo (小米)
|
||||
("glm-5", "GLM-5", "0.72", "2.30", "0", "0"),
|
||||
("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"),
|
||||
// MiMo (小米)
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
"Mimo V2 Flash",
|
||||
"MiMo V2 Flash",
|
||||
"0.09",
|
||||
"0.29",
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
|
||||
// Qwen 系列 (阿里巴巴)
|
||||
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
|
||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||
(
|
||||
"qwen3-235b-a22b",
|
||||
"Qwen3 235B-A22B",
|
||||
"0.70",
|
||||
"8.40",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-plus",
|
||||
"Qwen3 Coder Plus",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-flash",
|
||||
"Qwen3 Coder Flash",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-next",
|
||||
"Qwen3 Coder Next",
|
||||
"0.12",
|
||||
"0.75",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
|
||||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||
// Grok 系列 (xAI)
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
"Grok 4.20 Reasoning",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"Grok 4.20",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4-1-fast-reasoning",
|
||||
"Grok 4.1 Fast Reasoning",
|
||||
"0.20",
|
||||
"0.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4-1-fast-non-reasoning",
|
||||
"Grok 4.1 Fast",
|
||||
"0.20",
|
||||
"0.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
("grok-4", "Grok 4", "3", "15", "0.75", "0"),
|
||||
(
|
||||
"grok-code-fast-1",
|
||||
"Grok Code Fast",
|
||||
"0.20",
|
||||
"1.50",
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
||||
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
||||
// Mistral 系列
|
||||
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
||||
(
|
||||
"devstral-small-1.1",
|
||||
"Devstral Small 1.1",
|
||||
"0.07",
|
||||
"0.28",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
|
||||
(
|
||||
"devstral-medium",
|
||||
"Devstral Medium",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-large-3-2512",
|
||||
"Mistral Large 3",
|
||||
"0.50",
|
||||
"1.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-medium-3.1",
|
||||
"Mistral Medium 3.1",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-small-3.2-24b",
|
||||
"Mistral Small 3.2",
|
||||
"0.075",
|
||||
"0.20",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
|
||||
// Cohere 系列
|
||||
("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
|
||||
(
|
||||
"command-r-plus",
|
||||
"Cohere Command R+",
|
||||
"2.50",
|
||||
"10",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
|
||||
// OpenAI 补充
|
||||
("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
|
||||
("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
|
||||
("o1", "OpenAI o1", "15", "60", "7.50", "0"),
|
||||
("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
|
||||
("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
|
||||
("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
|
||||
("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
|
||||
];
|
||||
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
conn.execute(
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input,
|
||||
output,
|
||||
cache_read,
|
||||
cache_creation
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
stmt.execute(rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input,
|
||||
output,
|
||||
cache_read,
|
||||
cache_creation
|
||||
])
|
||||
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ pub enum AppError {
|
||||
McpValidation(String),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("HTTP {status}: {body}")]
|
||||
HttpStatus { status: u16, body: String },
|
||||
#[error("{zh} ({en})")]
|
||||
Localized {
|
||||
key: &'static str,
|
||||
|
||||
@@ -61,7 +61,12 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||
json5::from_str(&content).map_err(|e| {
|
||||
AppError::Config(format!(
|
||||
"Failed to parse OpenCode config: {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
|
||||
@@ -172,29 +172,6 @@ pub struct ProviderTestConfig {
|
||||
pub max_retries: Option<u32>,
|
||||
}
|
||||
|
||||
/// 供应商单独的代理配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderProxyConfig {
|
||||
/// 是否启用单独配置(false 时使用全局/系统代理)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// 代理类型:http, https, socks5
|
||||
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_type: Option<String>,
|
||||
/// 代理主机
|
||||
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_host: Option<String>,
|
||||
/// 代理端口
|
||||
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_port: Option<u16>,
|
||||
/// 代理用户名(可选)
|
||||
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_username: Option<String>,
|
||||
/// 代理密码(可选)
|
||||
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 认证绑定来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -262,9 +239,6 @@ pub struct ProviderMeta {
|
||||
/// 供应商单独的模型测试配置
|
||||
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub test_config: Option<ProviderTestConfig>,
|
||||
/// 供应商单独的代理配置
|
||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//!
|
||||
//! 参考实现: https://github.com/caozhiyuan/copilot-api
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
@@ -21,6 +23,9 @@ pub struct CopilotClassification {
|
||||
pub is_warmup: bool,
|
||||
/// 是否为上下文压缩请求
|
||||
pub is_compact: bool,
|
||||
/// 是否为 Claude Code 子代理请求(Agent tool 生成的 subagent)
|
||||
/// 子代理请求应设置 x-interaction-type=conversation-subagent,不计 premium interaction
|
||||
pub is_subagent: bool,
|
||||
}
|
||||
|
||||
/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。
|
||||
@@ -38,12 +43,17 @@ pub struct CopilotClassification {
|
||||
///
|
||||
/// `compact_detection`:是否启用 compact 检测。为 false 时跳过,
|
||||
/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。
|
||||
///
|
||||
/// `subagent_detection`:是否启用子代理检测。为 true 时,会扫描首条用户消息
|
||||
/// 中的 `__SUBAGENT_MARKER__` 标记,将子代理请求标记为不计费。
|
||||
pub fn classify_request(
|
||||
body: &Value,
|
||||
has_anthropic_beta: bool,
|
||||
compact_detection: bool,
|
||||
subagent_detection: bool,
|
||||
) -> CopilotClassification {
|
||||
let is_compact = compact_detection && is_compact_request(body);
|
||||
let is_subagent = subagent_detection && detect_subagent(body);
|
||||
|
||||
let messages = match body.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(msgs) if !msgs.is_empty() => msgs,
|
||||
@@ -52,6 +62,7 @@ pub fn classify_request(
|
||||
initiator: "user",
|
||||
is_warmup: is_warmup_request(body, has_anthropic_beta, false),
|
||||
is_compact: false,
|
||||
is_subagent,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -62,29 +73,33 @@ pub fn classify_request(
|
||||
// 只有 role=user 的消息需要细分
|
||||
if role != "user" {
|
||||
return CopilotClassification {
|
||||
initiator: "user",
|
||||
initiator: if is_subagent { "agent" } else { "user" },
|
||||
is_warmup: false,
|
||||
is_compact,
|
||||
is_subagent,
|
||||
};
|
||||
}
|
||||
|
||||
// 参考实现的判定逻辑(Messages API 路径):
|
||||
// 如果 content 是数组,检查是否有非 tool_result 的 block
|
||||
// 有 → "user",全是 tool_result → "agent"
|
||||
// 如果 content 是字符串 → "user"
|
||||
// 判定逻辑(与 copilot-api 的 merge-then-classify 效果对齐):
|
||||
// 只要 content 数组中包含 tool_result → 视为工具续写 → agent
|
||||
// 这覆盖了 skill/edit hook/plan follow-up 等常见场景,
|
||||
// 它们的 content 通常是 [tool_result, text] 混合形态。
|
||||
// copilot-api 通过先 merge(text 吸收进 tool_result)再 classify 实现同等效果;
|
||||
// 直接在分类层处理更稳健,不依赖 merge 启用状态和执行顺序。
|
||||
let is_user_initiated = match last_msg.get("content") {
|
||||
Some(content) if content.is_array() => {
|
||||
let blocks = content.as_array().unwrap();
|
||||
// 存在非 tool_result block → 用户发起
|
||||
blocks
|
||||
// 含有 tool_result → 工具续写(agent),否则 → 用户发起(user)
|
||||
!blocks
|
||||
.iter()
|
||||
.any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
|
||||
.any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result"))
|
||||
}
|
||||
Some(content) if content.is_string() => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let initiator = if !is_user_initiated || is_compact {
|
||||
// 子代理请求始终标记为 agent(即使首条消息包含用户文本)
|
||||
let initiator = if is_subagent || !is_user_initiated || is_compact {
|
||||
"agent"
|
||||
} else {
|
||||
"user"
|
||||
@@ -94,6 +109,7 @@ pub fn classify_request(
|
||||
initiator,
|
||||
is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact),
|
||||
is_compact,
|
||||
is_subagent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,23 +139,11 @@ fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -
|
||||
fn is_compact_request(body: &Value) -> bool {
|
||||
// 信号 1: system prompt 以 Claude Code compact 专用前缀开头
|
||||
// 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号
|
||||
if let Some(system) = body.get("system") {
|
||||
let system_text = if let Some(s) = system.as_str() {
|
||||
s.to_string()
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if system_text
|
||||
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let system_text = extract_system_text(body);
|
||||
if system_text
|
||||
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 信号 2 & 3: 检查最后一条用户消息中的机器生成特征
|
||||
@@ -259,8 +263,9 @@ pub fn merge_tool_results(mut body: Value) -> Value {
|
||||
|
||||
/// 基于最后一条用户消息内容生成确定性 Request ID。
|
||||
///
|
||||
/// 与参考实现对齐:
|
||||
/// CC Switch 额外策略(参考项目 copilot-api 使用随机 UUID):
|
||||
/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control)
|
||||
/// - 相同内容产生相同 ID,可能帮助 Copilot 去重
|
||||
/// - 找不到用户内容时退化为随机 UUID
|
||||
/// - 使用 UUID v4 格式
|
||||
pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
|
||||
@@ -285,8 +290,176 @@ pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于 session ID 生成稳定的 Interaction ID。
|
||||
///
|
||||
/// 与参考实现(copilot-api session.ts)对齐:
|
||||
/// - 同一主对话的所有请求共享同一个 interaction ID
|
||||
/// - 哈希输入: 仅 session ID(不包含消息内容,与 request ID 不同)
|
||||
/// - Copilot 用此 ID 将请求聚合为同一个 "interaction",影响 premium 计费归属
|
||||
/// - 空 session ID 时返回 None(不应注入随机值,避免 interaction 碎片化)
|
||||
pub fn deterministic_interaction_id(session_id: &str) -> Option<String> {
|
||||
if session_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"interaction:");
|
||||
hasher.update(session_id.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes.copy_from_slice(&result[..16]);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||
|
||||
Some(Uuid::from_bytes(bytes).to_string())
|
||||
}
|
||||
|
||||
/// 检测请求是否来自 Claude Code 子代理(Agent tool 生成的 subagent)。
|
||||
///
|
||||
/// Claude Code 的 Agent tool 会在子代理首条用户消息的 `<system-reminder>` 标签中
|
||||
/// 注入 `__SUBAGENT_MARKER__` JSON 标记,格式如:
|
||||
/// ```json
|
||||
/// {"__SUBAGENT_MARKER__": {"session_id": "...", "agent_id": "...", "agent_type": "..."}}
|
||||
/// ```
|
||||
///
|
||||
/// 扫描策略(与 copilot-api 的 subagent-marker.ts 对齐):
|
||||
/// 1. 遍历所有 user 消息(不仅是第一条,因为 context 压缩可能重排消息)
|
||||
/// 2. 在消息文本中查找 `__SUBAGENT_MARKER__` 关键字
|
||||
/// 3. 找到即判定为子代理请求
|
||||
fn detect_subagent(body: &Value) -> bool {
|
||||
// 信号 1: 显式 __SUBAGENT_MARKER__(Claude Code 2.x+ 自动注入)
|
||||
if extract_system_text(body).contains("__SUBAGENT_MARKER__") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
let text = extract_text_from_message(msg);
|
||||
if text.contains("__SUBAGENT_MARKER__") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 信号 2(fallback): metadata.user_id 包含子代理标识
|
||||
// Claude Code 的 Agent tool 会将 subagent session 标记为
|
||||
// "parentSessionId_agent_agentId" 格式,检测 "_agent_" 后缀
|
||||
if let Some(user_id) = body.pointer("/metadata/user_id").and_then(|v| v.as_str()) {
|
||||
// "_agent_" 是 Claude Code Agent tool 的内部标记
|
||||
if user_id.contains("_agent_") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 信号 3(fallback): system prompt 包含 Claude Code 子代理的典型框架文本
|
||||
// Agent tool 生成的子代理会在 system prompt 中包含由 Agent tool 注入的任务描述,
|
||||
// 但主对话的 system prompt 由 Claude Code CLI 直接生成,两者格式不同
|
||||
// 这个信号不够可靠(用户 prompt 也可能包含这些词),因此只作为辅助判据
|
||||
// 暂不启用,预留接口
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 清理孤立的 tool_result — 没有对应 tool_use 的 tool_result 转为 text block。
|
||||
///
|
||||
/// 场景:上下文压缩、消息截断等可能导致 assistant 消息中的 tool_use 被删除,
|
||||
/// 但后续 user 消息中的 tool_result 仍在。上游 API 可能因不匹配而报错/重试。
|
||||
///
|
||||
/// 与 copilot-api 的 `sanitizeOrphanToolResults` 对齐。
|
||||
pub fn sanitize_orphan_tool_results(mut body: Value) -> Value {
|
||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
Some(msgs) if msgs.len() >= 2 => msgs,
|
||||
_ => return body,
|
||||
};
|
||||
|
||||
// Anthropic 协议要求 tool_result 紧跟其对应 tool_use 所在的 assistant turn。
|
||||
// 只检查 messages[i-1](紧邻上一条 assistant)来判定是否 orphan,
|
||||
// 与参考实现 sanitizeOrphanToolResults 对齐。
|
||||
for i in 1..messages.len() {
|
||||
if messages[i].get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 收集紧邻上一条 assistant 的 tool_use id
|
||||
let prev_tool_use_ids: HashSet<String> =
|
||||
if messages[i - 1].get("role").and_then(|r| r.as_str()) == Some("assistant") {
|
||||
messages[i - 1]
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|blocks| {
|
||||
blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
.filter_map(|b| b.get("id").and_then(|i| i.as_str()).map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
// 上一条不是 assistant → 这条 user 中的所有 tool_result 都是 orphan
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
let content = match messages[i]
|
||||
.get_mut("content")
|
||||
.and_then(|c| c.as_array_mut())
|
||||
{
|
||||
Some(blocks) => blocks,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
for block in content.iter_mut() {
|
||||
if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
|
||||
continue;
|
||||
}
|
||||
let tool_use_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(|id| id.as_str())
|
||||
.unwrap_or("");
|
||||
// 空 tool_use_id 或不在紧邻 assistant 的 tool_use 中 → orphan
|
||||
if tool_use_id.is_empty() || !prev_tool_use_ids.contains(tool_use_id) {
|
||||
let content_text = match block.get("content") {
|
||||
Some(c) if c.is_string() => c.as_str().unwrap_or("").to_string(),
|
||||
Some(c) if c.is_array() => c
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => String::new(),
|
||||
};
|
||||
*block = serde_json::json!({
|
||||
"type": "text",
|
||||
"text": format!("[Tool result for {}]: {}", tool_use_id, content_text)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
// ─── 内部辅助 ─────────────────────────────────
|
||||
|
||||
/// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。
|
||||
fn extract_system_text(body: &Value) -> String {
|
||||
match body.get("system") {
|
||||
Some(s) if s.is_string() => s.as_str().unwrap_or("").to_string(),
|
||||
Some(arr) if arr.is_array() => arr
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找最后一条 user 消息的非 tool_result 文本内容。
|
||||
///
|
||||
/// 与参考实现的 `findLastUserContent` 对齐:
|
||||
@@ -433,7 +606,7 @@ mod tests {
|
||||
{"role": "user", "content": "Hello, please help me write some code"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
@@ -448,7 +621,7 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
@@ -468,15 +641,16 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_tool_result_with_text_block() {
|
||||
// 参考实现的关键场景:tool_result + text block
|
||||
// 有非 tool_result block → 仍然是 "user"
|
||||
// tool_result + text block(skill/edit hook/plan follow-up 的常见形态)
|
||||
// 含有 tool_result → 视为工具续写 → agent
|
||||
// 与 copilot-api 的 merge-then-classify 效果对齐
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
@@ -486,8 +660,8 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -496,14 +670,14 @@ mod tests {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": []
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_no_messages() {
|
||||
let body = json!({"model": "claude-sonnet-4-20250514"});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
@@ -517,7 +691,7 @@ mod tests {
|
||||
{"role": "user", "content": "Here is the conversation history to summarize..."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
@@ -533,7 +707,7 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
@@ -548,7 +722,7 @@ mod tests {
|
||||
{"role": "user", "content": "Summarize"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, false); // compact_detection=false
|
||||
let result = classify_request(&body, false, false, false); // compact_detection=false
|
||||
assert_eq!(result.initiator, "user"); // 不被标记为 agent
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
@@ -562,7 +736,7 @@ mod tests {
|
||||
{"role": "user", "content": "Please summarize the conversation so far into a concise summary."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
// 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
@@ -579,7 +753,7 @@ mod tests {
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=true, 无 tools → warmup
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert!(result.is_warmup);
|
||||
}
|
||||
|
||||
@@ -592,7 +766,7 @@ mod tests {
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=false → 不是 warmup
|
||||
let result = classify_request(&body, false, true);
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
@@ -606,7 +780,7 @@ mod tests {
|
||||
]
|
||||
});
|
||||
// 有 tools → 不是 warmup(即使有 anthropic-beta)
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
@@ -621,7 +795,7 @@ mod tests {
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
let result = classify_request(&body, true, true, false);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
@@ -831,6 +1005,44 @@ mod tests {
|
||||
assert!(Uuid::parse_str(&id).is_ok());
|
||||
}
|
||||
|
||||
// === deterministic_interaction_id 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_stable_for_same_session() {
|
||||
let id1 = deterministic_interaction_id("session_abc");
|
||||
let id2 = deterministic_interaction_id("session_abc");
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_differs_across_sessions() {
|
||||
let id1 = deterministic_interaction_id("session_abc");
|
||||
let id2 = deterministic_interaction_id("session_def");
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_differs_from_request_id() {
|
||||
let body = json!({
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let interaction = deterministic_interaction_id("session_abc").unwrap();
|
||||
let request = deterministic_request_id(&body, "session_abc");
|
||||
assert_ne!(interaction, request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_empty_session_is_none() {
|
||||
// 无 session 时不应生成 interaction ID(避免碎片化)
|
||||
assert!(deterministic_interaction_id("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_id_is_valid_uuid() {
|
||||
let id = deterministic_interaction_id("test_session").unwrap();
|
||||
assert!(Uuid::parse_str(&id).is_ok());
|
||||
}
|
||||
|
||||
// === compact 检测增强测试 ===
|
||||
|
||||
#[test]
|
||||
@@ -898,4 +1110,265 @@ mod tests {
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
// === detect_subagent 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_with_marker_in_user_message() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc123\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nPlease search the codebase for auth handlers"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
assert!(detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_with_marker_in_system() {
|
||||
let body = json!({
|
||||
"system": "You are an agent. {\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"plan-1\",\"agent_type\":\"Plan\"}}",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Design the implementation plan"}
|
||||
]
|
||||
});
|
||||
assert!(detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_no_marker() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, please help me write code"}
|
||||
]
|
||||
});
|
||||
assert!(!detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_via_metadata_user_id() {
|
||||
// fallback 信号: metadata.user_id 包含 "_agent_" 标记
|
||||
let body = json!({
|
||||
"metadata": {
|
||||
"user_id": "session_abc123_agent_explore-1"
|
||||
},
|
||||
"messages": [
|
||||
{"role": "user", "content": "Search for files"}
|
||||
]
|
||||
});
|
||||
assert!(detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_subagent_normal_user_id_not_matched() {
|
||||
// 普通 session ID 不应被误判
|
||||
let body = json!({
|
||||
"metadata": {
|
||||
"user_id": "session_abc123"
|
||||
},
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
assert!(!detect_subagent(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_subagent_sets_agent_initiator() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nSearch for files"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_subagent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_subagent_disabled_flag() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nSearch for files"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
// subagent_detection=false → 不检测子代理
|
||||
let result = classify_request(&body, false, true, false);
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_subagent);
|
||||
}
|
||||
|
||||
// === sanitize_orphan_tool_results 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_tool_results_converts_orphans() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Help me"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "tool_1", "content": "file contents"},
|
||||
{"type": "tool_result", "tool_use_id": "tool_orphan", "content": "orphan data"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
let msgs = result["messages"].as_array().unwrap();
|
||||
let last_content = msgs[2]["content"].as_array().unwrap();
|
||||
// tool_1 保留为 tool_result
|
||||
assert_eq!(last_content[0]["type"], "tool_result");
|
||||
// tool_orphan 转为 text
|
||||
assert_eq!(last_content[1]["type"], "text");
|
||||
assert!(last_content[1]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("tool_orphan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_tool_results_no_orphans() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "tool_1", "content": "ok"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body.clone());
|
||||
// 无孤立 tool_result,不应有变化
|
||||
assert_eq!(result["messages"][1]["content"][0]["type"], "tool_result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_non_adjacent_assistant_tool_use_is_orphan() {
|
||||
// tool_use 在更早的 assistant 中,但 tool_result 的紧邻上一条是另一个 assistant
|
||||
// → 对 Anthropic 协议来说这个 tool_result 是 orphan
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "step 1"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "old_tool", "name": "search", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "old_tool", "content": "found it"}
|
||||
]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "OK, now let me think..."}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "old_tool", "content": "stale ref"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
let msgs = result["messages"].as_array().unwrap();
|
||||
// messages[2]: 紧邻 assistant 有 old_tool → 保留
|
||||
assert_eq!(msgs[2]["content"][0]["type"], "tool_result");
|
||||
// messages[4]: 紧邻 assistant 无 tool_use → orphan → text
|
||||
assert_eq!(msgs[4]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_prev_not_assistant() {
|
||||
// tool_result 紧邻上一条是 user(非 assistant)→ 全部 orphan
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "data"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
assert_eq!(result["messages"][1]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
/// 关键场景:orphan tool_result(上下文压缩丢失了紧邻 tool_use)
|
||||
/// 在分类时仍应被视为 agent continuation,不能因为后续的 sanitize
|
||||
/// 将其转为 text 而变成 user 请求。
|
||||
///
|
||||
/// 这个测试验证 classify_request 在原始(未 sanitize)的 body 上
|
||||
/// 正确识别 orphan tool_result 为 agent。
|
||||
#[test]
|
||||
fn test_orphan_tool_result_classified_as_agent_before_sanitize() {
|
||||
// 场景:最后一条 user 消息全是 tool_result,但紧邻的 assistant
|
||||
// 消息里没有对应的 tool_use(因上下文压缩丢失了)
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "I'll help you with that."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "orphan_tool_1", "content": "file contents here"},
|
||||
{"type": "tool_result", "tool_use_id": "orphan_tool_2", "content": "another result"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
// 在原始 body 上分类 → 全是 tool_result → agent
|
||||
let classification = classify_request(&body, false, false, false);
|
||||
assert_eq!(classification.initiator, "agent");
|
||||
|
||||
// sanitize 后 → tool_result 变为 text → 如果再分类就会变成 user
|
||||
let sanitized = sanitize_orphan_tool_results(body);
|
||||
let classification_after = classify_request(&sanitized, false, false, false);
|
||||
assert_eq!(
|
||||
classification_after.initiator, "user",
|
||||
"sanitize 后 orphan tool_result 变为 text,分类变成 user — \
|
||||
这就是为什么分类必须在 sanitize 之前执行"
|
||||
);
|
||||
}
|
||||
|
||||
/// orphan tool_result + text 混合场景:
|
||||
/// 分类器直接识别含 tool_result 的消息为 agent(无论是否有 text block),
|
||||
/// 不依赖 merge 的执行顺序。即使 orphan tool_result 后续被 sanitize 转为 text,
|
||||
/// 分类结果在此之前已经确定为 agent。
|
||||
#[test]
|
||||
fn test_orphan_tool_result_with_text_classified_as_agent() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "Processing..."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "orphan_1", "content": "result data"},
|
||||
{"type": "text", "text": "Here's the output from the tool"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
// 含有 tool_result → agent(无论是否有 text block)
|
||||
let classification = classify_request(&body, false, false, false);
|
||||
assert_eq!(classification.initiator, "agent");
|
||||
|
||||
// sanitize 后 orphan tool_result 变为 text → 纯 text → 分类会变成 user
|
||||
// 但正确的执行顺序是先分类再 sanitize,所以这不是问题
|
||||
let sanitized = sanitize_orphan_tool_results(body);
|
||||
let classification_after = classify_request(&sanitized, false, false, false);
|
||||
assert_eq!(classification_after.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_orphan_empty_tool_use_id_is_orphan() {
|
||||
// tool_use_id 为空或缺失 → 无法匹配任何 tool_use → orphan
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "tool_1", "name": "read", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "", "content": "empty id"},
|
||||
{"type": "tool_result", "content": "missing id field"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = sanitize_orphan_tool_results(body);
|
||||
let content = result["messages"][1]["content"].as_array().unwrap();
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert_eq!(content[1]["type"], "text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,32 +776,42 @@ impl RequestForwarder {
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) ---
|
||||
// --- 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. Tool result 合并 — 必须在分类之前执行
|
||||
// 合并将 [tool_result, text] 变为 [tool_result(含text)],
|
||||
// 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block)
|
||||
if self.copilot_optimizer_config.tool_result_merging {
|
||||
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
|
||||
}
|
||||
|
||||
// 2. 在合并后的 body 上进行分类
|
||||
// 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={}",
|
||||
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}",
|
||||
classification.initiator,
|
||||
classification.is_warmup,
|
||||
classification.is_compact
|
||||
classification.is_compact,
|
||||
classification.is_subagent
|
||||
);
|
||||
|
||||
// 3. Warmup 小模型降级
|
||||
// 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 请求降级到模型: {}",
|
||||
@@ -812,22 +822,52 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// 预计算确定性 Request ID(在 body 被 move 之前)
|
||||
// 使用 session_id 从 body.metadata.user_id 或请求头提取
|
||||
let session_id = body
|
||||
.pointer("/metadata/user_id")
|
||||
// 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())
|
||||
.or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok()))
|
||||
.unwrap_or("");
|
||||
.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,
|
||||
&session_id,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some((classification, det_request_id))
|
||||
// 从 session ID 派生稳定的 interaction ID(同一主对话共享)
|
||||
let interaction_id =
|
||||
super::copilot_optimizer::deterministic_interaction_id(&session_id);
|
||||
|
||||
Some((classification, det_request_id, interaction_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1039,12 +1079,18 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
|
||||
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) {
|
||||
@@ -1055,6 +1101,19 @@ impl RequestForwarder {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 注入,需在原始头中去重)
|
||||
@@ -1069,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",
|
||||
@@ -1287,12 +1347,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
|
||||
@@ -1308,7 +1364,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);
|
||||
|
||||
@@ -601,7 +601,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::*;
|
||||
|
||||
@@ -81,13 +81,50 @@ pub fn transform_claude_request_for_api_format(
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
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.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);
|
||||
})
|
||||
.unwrap_or(&provider.id);
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||||
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
|
||||
let is_codex_oauth = provider
|
||||
@@ -455,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"),
|
||||
|
||||
@@ -85,8 +85,16 @@ 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,
|
||||
@@ -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);
|
||||
|
||||
@@ -661,7 +661,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"],
|
||||
@@ -682,7 +682,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"],
|
||||
|
||||
@@ -567,7 +567,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={}",
|
||||
@@ -907,6 +907,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
@@ -966,6 +967,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -291,8 +291,12 @@ pub struct CopilotOptimizerConfig {
|
||||
/// 确定性 Request ID(默认开启,P3 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub deterministic_request_id: bool,
|
||||
/// Warmup 小模型降级(默认关闭,P4 优先级,opt-in)
|
||||
#[serde(default)]
|
||||
/// 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")]
|
||||
@@ -300,7 +304,7 @@ pub struct CopilotOptimizerConfig {
|
||||
}
|
||||
|
||||
fn default_warmup_model() -> String {
|
||||
"gpt-4o-mini".to_string()
|
||||
"gpt-5-mini".to_string()
|
||||
}
|
||||
|
||||
impl Default for CopilotOptimizerConfig {
|
||||
@@ -311,7 +315,8 @@ impl Default for CopilotOptimizerConfig {
|
||||
tool_result_merging: true,
|
||||
compact_detection: true,
|
||||
deterministic_request_id: true,
|
||||
warmup_downgrade: false,
|
||||
subagent_detection: true,
|
||||
warmup_downgrade: true,
|
||||
warmup_model: "gpt-4o-mini".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@ pub async fn fetch_models(
|
||||
}
|
||||
|
||||
let models_url = build_models_url(base_url, is_full_url)?;
|
||||
let client = crate::proxy::http_client::get_for_provider(None);
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let response = client
|
||||
.get(&models_url)
|
||||
|
||||
@@ -1099,7 +1099,7 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
// One-time auth type detection to avoid repeated detection
|
||||
let auth_type = detect_gemini_auth_type(provider);
|
||||
|
||||
let mut env_map = json_to_env(&provider.settings_config)?;
|
||||
let env_map = json_to_env(&provider.settings_config)?;
|
||||
|
||||
// Prepare config to write to ~/.gemini/settings.json
|
||||
// Behavior:
|
||||
@@ -1143,17 +1143,12 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => {
|
||||
// Google official uses OAuth, clear env
|
||||
env_map.clear();
|
||||
// Google Official uses OAuth, no API key validation needed.
|
||||
// Write user's env vars as-is (e.g. GEMINI_MODEL, custom vars).
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Packycode => {
|
||||
// PackyCode provider, uses API Key (strict validation on switch)
|
||||
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Generic => {
|
||||
// Generic provider, uses API Key (strict validation on switch)
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
// API Key mode -- require GEMINI_API_KEY
|
||||
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
|
||||
@@ -1407,6 +1407,16 @@ impl ProviderService {
|
||||
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
|
||||
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
|
||||
|
||||
// Block switching to official providers when proxy takeover is active.
|
||||
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
|
||||
if should_hot_switch && _provider.category.as_deref() == Some("official") {
|
||||
return Err(AppError::localized(
|
||||
"switch.official_blocked_by_proxy",
|
||||
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
|
||||
"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
|
||||
));
|
||||
}
|
||||
|
||||
if should_hot_switch {
|
||||
// Proxy takeover mode: hot-switch only, don't write Live config
|
||||
log::info!(
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::services::provider::{
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||||
@@ -375,6 +376,26 @@ impl ProxyService {
|
||||
|
||||
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
||||
let _ = self.db.set_live_takeover_active(true).await;
|
||||
|
||||
// 8) Warn if the current provider is official (risk of account ban via proxy)
|
||||
if let Ok(Some(current_id)) =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app)
|
||||
{
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) {
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
if let Some(handle) = self.app_handle.read().await.as_ref() {
|
||||
let _ = handle.emit(
|
||||
"proxy-official-warning",
|
||||
serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"providerName": provider.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1548,6 +1569,14 @@ impl ProxyService {
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
|
||||
// Defense-in-depth: block official providers during proxy takeover
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let logical_target_changed =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
|
||||
.map_err(|e| format!("读取当前供应商失败: {e}"))?
|
||||
|
||||
@@ -278,7 +278,11 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
|
||||
continue;
|
||||
}
|
||||
|
||||
let request_id = format!("session:{}", msg.message_id);
|
||||
let request_id = format!(
|
||||
"{}{}",
|
||||
crate::proxy::usage::parser::SESSION_REQUEST_ID_PREFIX,
|
||||
msg.message_id
|
||||
);
|
||||
|
||||
// 跳过 output_tokens 为 0 的无意义条目
|
||||
if msg.output_tokens == 0 {
|
||||
@@ -379,6 +383,7 @@ fn insert_session_log_entry(
|
||||
cache_read_tokens: msg.cache_read_tokens,
|
||||
cache_creation_tokens: msg.cache_creation_tokens,
|
||||
model: Some(msg.model.clone()),
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = find_model_pricing_for_session(&conn, &msg.model);
|
||||
|
||||
@@ -474,6 +474,7 @@ fn insert_codex_session_entry(
|
||||
cache_read_tokens: delta.cached_input,
|
||||
cache_creation_tokens: 0,
|
||||
model: Some(model.to_string()),
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = find_codex_pricing(&conn, model);
|
||||
|
||||
@@ -261,6 +261,7 @@ fn insert_gemini_session_entry(
|
||||
cache_read_tokens: tokens.cached,
|
||||
cache_creation_tokens: 0,
|
||||
model: Some(model.to_string()),
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = find_gemini_pricing(&conn, model);
|
||||
|
||||
@@ -680,6 +680,13 @@ impl SkillService {
|
||||
found.display()
|
||||
);
|
||||
source = found;
|
||||
} else if temp_dir.join("SKILL.md").exists() {
|
||||
// 根级 Skill:仓库本身就是 skill,SKILL.md 直接在解压根目录
|
||||
log::info!(
|
||||
"Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir",
|
||||
target_name,
|
||||
);
|
||||
source = temp_dir.clone();
|
||||
} else {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
|
||||
@@ -218,9 +218,8 @@ impl StreamCheckService {
|
||||
.or_else(|| adapter.extract_auth(provider))
|
||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||
|
||||
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let client = crate::proxy::http_client::get_for_provider(proxy_config);
|
||||
// 获取 HTTP 客户端
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
@@ -272,34 +271,11 @@ impl StreamCheckService {
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
let tested_at = chrono::Utc::now().timestamp();
|
||||
|
||||
match result {
|
||||
Ok((status_code, model)) => {
|
||||
let health_status =
|
||||
Self::determine_status(response_time, config.degraded_threshold_ms);
|
||||
Ok(StreamCheckResult {
|
||||
status: health_status,
|
||||
success: true,
|
||||
message: "Check succeeded".to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: Some(status_code),
|
||||
model_used: model,
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
}),
|
||||
}
|
||||
Ok(Self::build_stream_check_result(
|
||||
result,
|
||||
response_time,
|
||||
config.degraded_threshold_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// Claude 流式检查
|
||||
@@ -475,7 +451,7 @@ impl StreamCheckService {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
return Err(Self::http_status_error(status, error_text));
|
||||
}
|
||||
|
||||
// 流式读取:只需首个 chunk
|
||||
@@ -555,7 +531,7 @@ impl StreamCheckService {
|
||||
if i == 0 && status == 404 && urls.len() > 1 {
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
return Err(Self::http_status_error(status, error_text));
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
@@ -630,7 +606,7 @@ impl StreamCheckService {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
return Err(Self::http_status_error(status, error_text));
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
@@ -658,9 +634,8 @@ impl StreamCheckService {
|
||||
config: &StreamCheckConfig,
|
||||
start: Instant,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let client = crate::proxy::http_client::get_for_provider(proxy_config);
|
||||
// 获取 HTTP 客户端
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
@@ -718,16 +693,25 @@ impl StreamCheckService {
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
},
|
||||
Err(e) => StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
},
|
||||
Err(e) => {
|
||||
let (http_status, message) = match &e {
|
||||
AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
Self::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message,
|
||||
response_time_ms: Some(response_time),
|
||||
http_status,
|
||||
model_used: String::new(),
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,6 +1109,39 @@ impl StreamCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 HTTP 状态码错误,截断过长的响应体
|
||||
fn http_status_error(status: u16, body: String) -> AppError {
|
||||
let body = if body.len() > 200 {
|
||||
// 安全截断:找到 200 字节内最近的 char 边界
|
||||
let mut end = 200;
|
||||
while end > 0 && !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &body[..end])
|
||||
} else {
|
||||
body
|
||||
};
|
||||
AppError::HttpStatus { status, body }
|
||||
}
|
||||
|
||||
/// 将 HTTP 状态码映射为简短的分类标签
|
||||
pub(crate) fn classify_http_status(status: u16) -> &'static str {
|
||||
match status {
|
||||
400 => "Bad request (400)",
|
||||
401 => "Auth rejected (401)",
|
||||
402 => "Payment required (402)",
|
||||
403 => "Access denied (403)",
|
||||
404 => "Not found (404)",
|
||||
429 => "Rate limited (429)",
|
||||
500 => "Internal server error (500)",
|
||||
502 => "Bad gateway (502)",
|
||||
503 => "Service unavailable (503)",
|
||||
504 => "Gateway timeout (504)",
|
||||
s if (500..600).contains(&s) => "Server error",
|
||||
_ => "HTTP error",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_test_model(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
|
||||
@@ -9,10 +9,10 @@ use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "claude";
|
||||
const TITLE_MAX_CHARS: usize = 80;
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let root = get_claude_config_dir().join("projects");
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "codex";
|
||||
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut first_user_message: Option<String> = None;
|
||||
|
||||
// Extract metadata from head lines
|
||||
// Extract metadata and first user message from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -158,6 +160,29 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extract first user message as title candidate
|
||||
if first_user_message.is_none()
|
||||
&& value.get("type").and_then(Value::as_str) == Some("response_item")
|
||||
{
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if payload.get("type").and_then(Value::as_str) == Some("message")
|
||||
&& payload.get("role").and_then(Value::as_str) == Some("user")
|
||||
{
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") {
|
||||
first_user_message = Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if session_id.is_some()
|
||||
&& project_dir.is_some()
|
||||
&& created_at.is_some()
|
||||
&& first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last_active_at and summary from tail lines (reverse order)
|
||||
@@ -190,10 +215,14 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
let title = first_user_message
|
||||
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
|
||||
.or_else(|| {
|
||||
project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|v| v.to_string())
|
||||
});
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
@@ -261,6 +290,82 @@ mod tests {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_first_user_message_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_skips_agents_md_injection() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"<permissions>\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\n<INSTRUCTIONS>Do stuff</INSTRUCTIONS>\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
// Should skip AGENTS.md injection and use the real user message
|
||||
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/my-project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
// No user message → falls back to dir basename
|
||||
assert_eq!(meta.title.as_deref(), Some("my-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_truncates_long_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
let long_msg = "a".repeat(200);
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\
|
||||
{{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
let title = meta.title.unwrap();
|
||||
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
|
||||
assert!(title.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_includes_function_call_and_output() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
@@ -12,10 +13,20 @@ use crate::{
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "openclaw";
|
||||
|
||||
/// Strip trailing `\n[message_id: ...]` metadata injected by OpenClaw gateway.
|
||||
fn strip_message_id_suffix(text: &str) -> &str {
|
||||
if let Some(pos) = text.rfind("\n[message_id:") {
|
||||
text[..pos].trim_end()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let agents_dir = get_openclaw_dir().join("agents");
|
||||
if !agents_dir.exists() {
|
||||
@@ -46,22 +57,15 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let display_names = load_display_names(&sessions_dir);
|
||||
|
||||
for entry in session_entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
// Skip sessions.json index file
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n == "sessions.json")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
if let Some(meta) = parse_session(&path, Some(&display_names)) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +123,7 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
let meta = parse_session(path, None).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw session metadata: {}",
|
||||
path.display()
|
||||
@@ -149,15 +153,46 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<boo
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
/// Read `sessions.json` index and build a sessionId → displayName lookup map.
|
||||
/// Returns an empty map if the file does not exist or cannot be parsed.
|
||||
fn load_display_names(sessions_dir: &Path) -> HashMap<String, String> {
|
||||
let index_path = sessions_dir.join("sessions.json");
|
||||
let content = match std::fs::read_to_string(&index_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
let index: serde_json::Map<String, Value> = match serde_json::from_str(&content) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for (_key, entry) in &index {
|
||||
if let (Some(id), Some(name)) = (
|
||||
entry.get("sessionId").and_then(Value::as_str),
|
||||
entry.get("displayName").and_then(Value::as_str),
|
||||
) {
|
||||
if !name.is_empty() {
|
||||
map.insert(id.to_string(), name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn parse_session(
|
||||
path: &Path,
|
||||
display_names: Option<&HashMap<String, String>>,
|
||||
) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
let mut first_user_message: Option<String> = None;
|
||||
|
||||
// Extract metadata and first message summary from head lines
|
||||
// Extract metadata, summary, and first user message from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -189,15 +224,31 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// OpenClaw summary is the first message content
|
||||
if event_type == "message" && summary.is_none() {
|
||||
if event_type == "message" {
|
||||
if let Some(message) = value.get("message") {
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
summary = Some(text);
|
||||
let cleaned = strip_message_id_suffix(&text);
|
||||
if !cleaned.trim().is_empty() {
|
||||
if first_user_message.is_none()
|
||||
&& message.get("role").and_then(Value::as_str) == Some("user")
|
||||
{
|
||||
first_user_message = Some(cleaned.trim().to_string());
|
||||
}
|
||||
if summary.is_none() {
|
||||
summary = Some(cleaned.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if session_id.is_some()
|
||||
&& cwd.is_some()
|
||||
&& created_at.is_some()
|
||||
&& summary.is_some()
|
||||
&& first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last_active_at from tail lines (reverse order)
|
||||
@@ -221,10 +272,17 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
});
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = cwd
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string());
|
||||
// Title priority: displayName (from sessions.json) > first user message > dir basename
|
||||
let title = display_names
|
||||
.and_then(|m| m.get(&session_id))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|t| truncate_summary(t, TITLE_MAX_CHARS))
|
||||
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
|
||||
.or_else(|| {
|
||||
cwd.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
@@ -284,6 +342,93 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_first_user_message_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-abc.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_display_name_overrides_user_message() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path();
|
||||
|
||||
let path = sessions_dir.join("session-abc.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
std::fs::write(
|
||||
sessions_dir.join("sessions.json"),
|
||||
r#"{
|
||||
"agent:main:main": {
|
||||
"sessionId": "session-abc",
|
||||
"displayName": "重构登录模块"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write index");
|
||||
|
||||
let display_names = load_display_names(sessions_dir);
|
||||
let meta = parse_session(&path, Some(&display_names)).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("重构登录模块"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-def.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-def\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
// No user message and no displayName → falls back to dir basename
|
||||
assert_eq!(meta.title.as_deref(), Some("my-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_truncates_long_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-trunc.jsonl");
|
||||
let long_msg = "a".repeat(200);
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{{\"type\":\"session\",\"id\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
|
||||
{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
let title = meta.title.unwrap();
|
||||
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
|
||||
assert!(title.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_updates_index_and_removes_jsonl() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -5,6 +5,9 @@ use std::path::Path;
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Maximum number of characters for session titles (shared across providers).
|
||||
pub const TITLE_MAX_CHARS: usize = 80;
|
||||
|
||||
/// Read the first `head_n` lines and last `tail_n` lines from a file.
|
||||
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
|
||||
pub fn read_head_tail_lines(
|
||||
|
||||
+22
-2
@@ -297,6 +297,9 @@ pub fn create_tray_menu(
|
||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// Pre-compute proxy running state (used to disable official providers in tray menu)
|
||||
let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running());
|
||||
|
||||
// 每个应用类型折叠为子菜单,避免供应商过多时菜单过长
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
if !visible_apps.is_visible(§ion.app_type) {
|
||||
@@ -327,15 +330,32 @@ pub fn create_tray_menu(
|
||||
};
|
||||
let submenu_id = format!("submenu_{}", app_type_str);
|
||||
|
||||
// Check if this app is under proxy takeover (for disabling official providers)
|
||||
let is_app_taken_over = is_proxy_running
|
||||
&& (futures::executor::block_on(app_state.db.get_live_backup(app_type_str))
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
|| app_state
|
||||
.proxy_service
|
||||
.detect_takeover_in_live_config_for_app(§ion.app_type));
|
||||
|
||||
let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label);
|
||||
|
||||
for (id, provider) in sort_providers(&providers) {
|
||||
let is_current = current_id == *id;
|
||||
let is_official_blocked =
|
||||
is_app_taken_over && provider.category.as_deref() == Some("official");
|
||||
let label = if is_official_blocked {
|
||||
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
|
||||
} else {
|
||||
provider.name.clone()
|
||||
};
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
&label,
|
||||
!is_official_blocked, // disabled when blocked
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
|
||||
@@ -104,8 +104,7 @@ pub async fn execute_usage_script(
|
||||
)
|
||||
})?;
|
||||
|
||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
|
||||
// 5. 验证请求 URL(HTTPS 强制 + 同源检查)
|
||||
validate_request_url(&request.url, base_url, is_custom_template)?;
|
||||
|
||||
// 6. 发送 HTTP 请求
|
||||
@@ -468,19 +467,10 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
||||
));
|
||||
}
|
||||
|
||||
// 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
|
||||
if is_suspicious_hostname(hostname) {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.base_url_suspicious",
|
||||
"base_url 包含可疑的主机名",
|
||||
"base_url contains a suspicious hostname",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
||||
/// 验证请求 URL 是否安全(HTTPS 强制 + 同源检查)
|
||||
fn validate_request_url(
|
||||
request_url: &str,
|
||||
base_url: &str,
|
||||
@@ -561,151 +551,11 @@ fn validate_request_url(
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||
if let Some(host) = parsed_request.host_str() {
|
||||
let base_host = parsed_base.host_str().unwrap_or("");
|
||||
|
||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.private_ip_blocked",
|
||||
"禁止访问私有 IP 地址",
|
||||
"Access to private IP addresses is blocked",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自定义模板模式:没有 base_url,需要额外的安全检查
|
||||
// 禁止访问私有 IP 地址(SSRF 防护)
|
||||
if let Some(host) = parsed_request.host_str() {
|
||||
if is_private_ip(host) && !is_request_loopback {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.private_ip_blocked",
|
||||
"禁止访问私有 IP 地址(localhost 除外)",
|
||||
"Access to private IP addresses is blocked (localhost allowed)",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否为私有 IP 地址
|
||||
fn is_private_ip(host: &str) -> bool {
|
||||
// localhost 检查
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 尝试解析为IP地址
|
||||
if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {
|
||||
return is_private_ip_addr(ip_addr);
|
||||
}
|
||||
|
||||
// 如果不是IP地址,不是私有IP
|
||||
false
|
||||
}
|
||||
|
||||
/// 使用标准库API检查IP地址是否为私有地址
|
||||
fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(ipv4) => {
|
||||
let octets = ipv4.octets();
|
||||
|
||||
// 0.0.0.0/8 (包括未指定地址)
|
||||
if octets[0] == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// RFC1918 私有地址范围
|
||||
// 10.0.0.0/8
|
||||
if octets[0] == 10 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
||||
if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 192.168.0.0/16
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 其他特殊地址
|
||||
// 169.254.0.0/16 (链路本地地址)
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 127.0.0.0/8 (环回地址)
|
||||
if octets[0] == 127 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
std::net::IpAddr::V6(ipv6) => {
|
||||
// IPv6 私有地址检查 - 使用标准库方法
|
||||
|
||||
// ::1 (环回地址)
|
||||
if ipv6.is_loopback() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 唯一本地地址 (fc00::/7)
|
||||
// Rust 1.70+ 可以使用 ipv6.is_unique_local()
|
||||
// 但为了兼容性,我们手动检查
|
||||
let first_segment = ipv6.segments()[0];
|
||||
if (first_segment & 0xfe00) == 0xfc00 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 链路本地地址 (fe80::/10)
|
||||
if (first_segment & 0xffc0) == 0xfe80 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未指定地址 ::
|
||||
if ipv6.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否为可疑的主机名(只检查明显不安全的模式)
|
||||
fn is_suspicious_hostname(hostname: &str) -> bool {
|
||||
// 空主机名
|
||||
if hostname.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查明显的主机名格式问题
|
||||
if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
|
||||
if hostname.parse::<std::net::IpAddr>().is_ok() {
|
||||
// IP地址格式的,在这里不直接拒绝,让同源检查来处理
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否包含明显不当的字符
|
||||
let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
|
||||
if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 判断 URL 是否指向本机(localhost / loopback)
|
||||
fn is_loopback_host(url: &Url) -> bool {
|
||||
match url.host() {
|
||||
@@ -720,77 +570,6 @@ fn is_loopback_host(url: &Url) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_private_ip_validation() {
|
||||
// 测试IPv4私网地址
|
||||
|
||||
// RFC1918私网地址 - 应该返回true
|
||||
assert!(is_private_ip("10.0.0.1"));
|
||||
assert!(is_private_ip("10.255.255.254"));
|
||||
assert!(is_private_ip("172.16.0.1"));
|
||||
assert!(is_private_ip("172.31.255.255"));
|
||||
assert!(is_private_ip("192.168.0.1"));
|
||||
assert!(is_private_ip("192.168.255.255"));
|
||||
|
||||
// 链路本地地址 - 应该返回true
|
||||
assert!(is_private_ip("169.254.0.1"));
|
||||
assert!(is_private_ip("169.254.255.255"));
|
||||
|
||||
// 环回地址 - 应该返回true
|
||||
assert!(is_private_ip("127.0.0.1"));
|
||||
assert!(is_private_ip("localhost"));
|
||||
|
||||
// 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
|
||||
assert!(!is_private_ip("172.0.0.1"));
|
||||
assert!(!is_private_ip("172.15.255.255"));
|
||||
assert!(!is_private_ip("172.32.0.1"));
|
||||
assert!(!is_private_ip("172.64.0.1"));
|
||||
assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
|
||||
assert!(!is_private_ip("172.68.0.1"));
|
||||
assert!(!is_private_ip("172.100.50.25"));
|
||||
assert!(!is_private_ip("172.255.255.255"));
|
||||
|
||||
// 其他公网地址 - 应该返回false
|
||||
assert!(!is_private_ip("8.8.8.8")); // Google DNS
|
||||
assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
|
||||
assert!(!is_private_ip("208.67.222.222")); // OpenDNS
|
||||
assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
|
||||
|
||||
// 域名 - 应该返回false
|
||||
assert!(!is_private_ip("api.example.com"));
|
||||
assert!(!is_private_ip("www.google.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_private_validation() {
|
||||
// IPv6私网地址
|
||||
assert!(is_private_ip("::1")); // 环回地址
|
||||
assert!(is_private_ip("fc00::1")); // 唯一本地地址
|
||||
assert!(is_private_ip("fd00::1")); // 唯一本地地址
|
||||
assert!(is_private_ip("fe80::1")); // 链路本地地址
|
||||
assert!(is_private_ip("::")); // 未指定地址
|
||||
|
||||
// IPv6公网地址 - 应该返回false(修复的重点)
|
||||
assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
|
||||
assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
|
||||
assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
|
||||
assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
|
||||
|
||||
// 测试包含 ::1 子串但不是环回地址的公网地址
|
||||
assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
|
||||
assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hostname_bypass_prevention() {
|
||||
// 看起来像本地,但实际是域名
|
||||
assert!(!is_private_ip("127.0.0.1.evil.com"));
|
||||
assert!(!is_private_ip("localhost.evil.com"));
|
||||
|
||||
// 0.0.0.0 应该被视为本地/阻断
|
||||
assert!(is_private_ip("0.0.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_https_bypass_prevention() {
|
||||
// 非本地域名的 HTTP 应该被拒绝
|
||||
@@ -801,37 +580,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// 边界情况测试
|
||||
assert!(is_private_ip("172.16.0.0")); // RFC1918起始
|
||||
assert!(is_private_ip("172.31.255.255")); // RFC1918结束
|
||||
assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
|
||||
assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
|
||||
assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
|
||||
assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
|
||||
|
||||
// 紧邻RFC1918的公网地址 - 应该返回false
|
||||
assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
|
||||
assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ip_addr_parsing() {
|
||||
// 测试IP地址解析功能
|
||||
let ipv4_private = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(is_private_ip_addr(ipv4_private));
|
||||
|
||||
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(!is_private_ip_addr(ipv4_public));
|
||||
|
||||
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(is_private_ip_addr(ipv6_private));
|
||||
|
||||
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(!is_private_ip_addr(ipv6_public));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_comparison() {
|
||||
// 测试端口比较逻辑是否正确处理默认端口和显式端口
|
||||
|
||||
@@ -525,7 +525,7 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_google_official_gemini_sets_oauth_security() {
|
||||
fn switch_google_official_gemini_preserves_env_vars() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
@@ -540,7 +540,9 @@ fn switch_google_official_gemini_sets_oauth_security() {
|
||||
"google-official".to_string(),
|
||||
"Google".to_string(),
|
||||
json!({
|
||||
"env": {}
|
||||
"env": {
|
||||
"GEMINI_MODEL": "gemini-2.5-pro"
|
||||
}
|
||||
}),
|
||||
Some("https://ai.google.dev".to_string()),
|
||||
);
|
||||
@@ -558,23 +560,30 @@ fn switch_google_official_gemini_sets_oauth_security() {
|
||||
ProviderService::switch(&state, AppType::Gemini, "google-official")
|
||||
.expect("switching to Google official Gemini should succeed");
|
||||
|
||||
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
// Verify env vars are preserved in ~/.gemini/.env
|
||||
let env_path = home.join(".gemini").join(".env");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings.json should exist at {}",
|
||||
gemini_settings.display()
|
||||
env_path.exists(),
|
||||
"Gemini .env should exist at {}",
|
||||
env_path.display()
|
||||
);
|
||||
let env_content = std::fs::read_to_string(&env_path).expect("read gemini .env");
|
||||
assert!(
|
||||
env_content.contains("GEMINI_MODEL=gemini-2.5-pro"),
|
||||
"GEMINI_MODEL should be preserved in .env, got: {env_content}"
|
||||
);
|
||||
|
||||
// Verify OAuth security flag is still set correctly
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||
let gemini_value: serde_json::Value =
|
||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
|
||||
|
||||
assert_eq!(
|
||||
gemini_value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("oauth-personal"),
|
||||
"Gemini settings json should reflect oauth-personal for Google Official"
|
||||
"OAuth security flag should still be set"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user