第一个版本
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
exports/
|
||||||
|
*.log
|
||||||
|
.mitmproxy/
|
||||||
220
README.md
Normal file
220
README.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# LLM Proxy - OpenAI API 代理和训练数据收集工具
|
||||||
|
|
||||||
|
一个透明的 HTTP 代理服务器,用于拦截和保存 LLM API 请求,自动导出为 JSONL 格式的训练数据。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ **透明代理**:拦截所有 `/v1/` 开头的 LLM API 请求
|
||||||
|
- ✅ **零配置**:无需在代理中配置 API Key,直接使用客户端的 Key
|
||||||
|
- ✅ **多提供商支持**:支持 OpenAI、Anthropic、GLM、OpenRouter 等所有 OpenAI 兼容的 API
|
||||||
|
- ✅ **智能解析**:自动识别和解析 LLM 请求,忽略其他请求
|
||||||
|
- ✅ **思考过程保存**:自动保存模型的推理内容(reasoning)
|
||||||
|
- ✅ **多轮对话支持**:完整保存对话上下文
|
||||||
|
- ✅ **JSONL 导出**:一键导出为标准训练数据格式
|
||||||
|
- ✅ **SQLite 存储**:轻量级数据库,无需额外配置
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/mitmproxy/mitmproxy.git
|
||||||
|
cd mitmproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 启动代理服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python start_proxy.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听 `127.0.0.1:8080`
|
||||||
|
|
||||||
|
### 配置系统代理
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
1. 打开"设置" → "网络和 Internet" → "代理"
|
||||||
|
2. 开启"使用代理服务器"
|
||||||
|
3. 地址:`127.0.0.1`
|
||||||
|
4. 端口:`8080`
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 8080
|
||||||
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
在浏览器或系统设置中配置 HTTP/HTTPS 代理为 `127.0.0.1:8080`
|
||||||
|
|
||||||
|
### 使用客户端
|
||||||
|
|
||||||
|
#### Trae
|
||||||
|
|
||||||
|
1. 启动代理服务器
|
||||||
|
2. 配置系统代理(见上)
|
||||||
|
3. 在 Trae 中正常使用,配置任何 API 提供商和 Key
|
||||||
|
4. 所有请求自动被拦截和保存
|
||||||
|
|
||||||
|
#### CherryStudio
|
||||||
|
|
||||||
|
**方法 1:配置自定义提供商**
|
||||||
|
1. 打开 CherryStudio 设置 → 模型服务
|
||||||
|
2. 添加自定义提供商
|
||||||
|
3. API 地址:`http://127.0.0.1:8080/v1`
|
||||||
|
4. API Key:任意值(代理会忽略)
|
||||||
|
5. 添加你使用的模型
|
||||||
|
|
||||||
|
**方法 2:使用系统代理**
|
||||||
|
1. 启动代理服务器
|
||||||
|
2. 配置系统代理(见上)
|
||||||
|
3. 在 CherryStudio 中正常使用
|
||||||
|
|
||||||
|
### 导出训练数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 导出所有对话(包含思考过程)
|
||||||
|
python export.py
|
||||||
|
|
||||||
|
# 导出指定文件
|
||||||
|
python export.py --output my_data.jsonl
|
||||||
|
|
||||||
|
# 不包含思考过程
|
||||||
|
python export.py --no-reasoning
|
||||||
|
|
||||||
|
# 包含元数据
|
||||||
|
python export.py --with-metadata
|
||||||
|
|
||||||
|
# 查看数据库统计
|
||||||
|
python export.py --stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置文件
|
||||||
|
|
||||||
|
编辑 `config.json` 来自定义配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"proxy": {
|
||||||
|
"listen_port": 8080,
|
||||||
|
"listen_host": "127.0.0.1"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"path": "llm_data.db"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"enabled": true,
|
||||||
|
"path_patterns": ["/v1/"],
|
||||||
|
"save_all_requests": false
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"output_dir": "exports",
|
||||||
|
"include_reasoning": true,
|
||||||
|
"include_metadata": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSONL 格式
|
||||||
|
|
||||||
|
导出的 JSONL 文件格式:
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"messages": [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello!"}, {"role": "assistant", "content": "Hi there!", "reasoning": "The user greeted me, so I should respond politely."}]}
|
||||||
|
{"messages": [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is 2+2?"}, {"role": "assistant", "content": "2+2 equals 4.", "reasoning": "This is a simple arithmetic problem. 2+2 = 4."}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库结构
|
||||||
|
|
||||||
|
### conversations 表
|
||||||
|
- `id`: 主键
|
||||||
|
- `conversation_id`: 对话 ID
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `updated_at`: 更新时间
|
||||||
|
|
||||||
|
### requests 表
|
||||||
|
- `id`: 主键
|
||||||
|
- `request_id`: 请求 ID
|
||||||
|
- `conversation_id`: 对话 ID(外键)
|
||||||
|
- `model`: 模型名称
|
||||||
|
- `messages`: 消息列表(JSON)
|
||||||
|
- `request_body`: 完整请求体(JSON)
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
|
||||||
|
### responses 表
|
||||||
|
- `id`: 主键
|
||||||
|
- `request_id`: 请求 ID(外键)
|
||||||
|
- `response_body`: 完整响应体(JSON)
|
||||||
|
- `reasoning_content`: 思考过程
|
||||||
|
- `tokens_used`: 使用的 token 数量
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
1. **拦截请求**:代理拦截所有 `/v1/` 开头的 HTTP 请求
|
||||||
|
2. **智能解析**:尝试解析请求体,识别是否为 LLM API 请求
|
||||||
|
3. **保存请求**:将请求信息保存到 SQLite 数据库
|
||||||
|
4. **透明转发**:保持原始 Authorization header,转发到目标服务器
|
||||||
|
5. **保存响应**:接收响应后,保存响应内容和思考过程
|
||||||
|
6. **导出数据**:随时导出为 JSONL 格式用于训练
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
### HTTPS 证书
|
||||||
|
|
||||||
|
如果客户端使用 HTTPS 连接到 API(如 `https://api.openai.com`),需要:
|
||||||
|
|
||||||
|
1. 安装 mitmproxy 证书到系统信任库
|
||||||
|
2. 或者在客户端配置中使用 HTTP(如 `http://api.openai.com`)
|
||||||
|
|
||||||
|
### 证书安装
|
||||||
|
|
||||||
|
首次运行代理时,mitmproxy 会生成证书:
|
||||||
|
|
||||||
|
- Windows: `%USERPROFILE%\.mitmproxy\mitmproxy-ca-cert.pem`
|
||||||
|
- macOS/Linux: `~/.mitmproxy/mitmproxy-ca-cert.pem`
|
||||||
|
|
||||||
|
将证书安装到系统信任库即可。
|
||||||
|
|
||||||
|
### 隐私和安全
|
||||||
|
|
||||||
|
- 代理不会保存 API Key
|
||||||
|
- 所有数据存储在本地 SQLite 数据库
|
||||||
|
- 请妥善保管导出的训练数据
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 请求没有被拦截
|
||||||
|
|
||||||
|
1. 检查系统代理是否正确配置
|
||||||
|
2. 检查代理服务器是否正在运行
|
||||||
|
3. 检查请求路径是否包含 `/v1/`
|
||||||
|
|
||||||
|
### HTTPS 请求失败
|
||||||
|
|
||||||
|
1. 安装 mitmproxy 证书到系统信任库
|
||||||
|
2. 或者在客户端配置中使用 HTTP 而不是 HTTPS
|
||||||
|
|
||||||
|
### 数据库错误
|
||||||
|
|
||||||
|
1. 检查数据库文件权限
|
||||||
|
2. 删除 `llm_data.db` 重新初始化
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
20
config.json
Normal file
20
config.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"proxy": {
|
||||||
|
"listen_port": 8080,
|
||||||
|
"listen_host": "127.0.0.1"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"path": "llm_data.db"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"enabled": true,
|
||||||
|
"path_patterns": ["/v1/", "/chat/completions", "/completions"],
|
||||||
|
"host_patterns": ["deepseek.com", "openrouter.ai", "api.openai.com"],
|
||||||
|
"save_all_requests": false
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"output_dir": "exports",
|
||||||
|
"include_reasoning": true,
|
||||||
|
"include_metadata": false
|
||||||
|
}
|
||||||
|
}
|
||||||
243
database.py
Normal file
243
database.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class LLMDatabase:
|
||||||
|
def __init__(self, db_path: str = "llm_data.db"):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.init_database()
|
||||||
|
|
||||||
|
def get_connection(self):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_database(self):
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conversation_id TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
request_id TEXT UNIQUE NOT NULL,
|
||||||
|
conversation_id TEXT,
|
||||||
|
model TEXT,
|
||||||
|
messages TEXT,
|
||||||
|
request_body TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS responses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
response_body TEXT,
|
||||||
|
reasoning_content TEXT,
|
||||||
|
tokens_used INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (request_id) REFERENCES requests(request_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversation_id ON requests(conversation_id)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_id ON responses(request_id)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_or_create_conversation(self, conversation_id: Optional[str] = None) -> str:
|
||||||
|
if conversation_id is None:
|
||||||
|
conversation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO conversations (conversation_id)
|
||||||
|
VALUES (?)
|
||||||
|
""", (conversation_id,))
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE conversations SET updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
""", (conversation_id,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return conversation_id
|
||||||
|
|
||||||
|
def save_request(self, request_id: str, model: str, messages: List[Dict[str, Any]],
|
||||||
|
request_body: Dict[str, Any], conversation_id: Optional[str] = None) -> None:
|
||||||
|
conversation_id = self.get_or_create_conversation(conversation_id)
|
||||||
|
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO requests
|
||||||
|
(request_id, conversation_id, model, messages, request_body)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
request_id,
|
||||||
|
conversation_id,
|
||||||
|
model,
|
||||||
|
json.dumps(messages, ensure_ascii=False),
|
||||||
|
json.dumps(request_body, ensure_ascii=False)
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def save_response(self, request_id: str, response_body: Dict[str, Any],
|
||||||
|
reasoning_content: Optional[str] = None, tokens_used: Optional[int] = None) -> None:
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO responses
|
||||||
|
(request_id, response_body, reasoning_content, tokens_used)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
request_id,
|
||||||
|
json.dumps(response_body, ensure_ascii=False),
|
||||||
|
reasoning_content,
|
||||||
|
tokens_used
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_conversation_messages(self, conversation_id: str) -> List[Dict[str, Any]]:
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT r.messages, resp.response_body, resp.reasoning_content
|
||||||
|
FROM requests r
|
||||||
|
LEFT JOIN responses resp ON r.request_id = resp.request_id
|
||||||
|
WHERE r.conversation_id = ?
|
||||||
|
ORDER BY r.created_at
|
||||||
|
""", (conversation_id,))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for row in rows:
|
||||||
|
request_messages = json.loads(row['messages'])
|
||||||
|
response_body = json.loads(row['response_body']) if row['response_body'] else None
|
||||||
|
reasoning_content = row['reasoning_content']
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
for msg in request_messages:
|
||||||
|
messages.append(msg)
|
||||||
|
else:
|
||||||
|
max_prefix = min(len(messages), len(request_messages))
|
||||||
|
prefix_len = 0
|
||||||
|
while prefix_len < max_prefix and messages[prefix_len] == request_messages[prefix_len]:
|
||||||
|
prefix_len += 1
|
||||||
|
for msg in request_messages[prefix_len:]:
|
||||||
|
messages.append(msg)
|
||||||
|
|
||||||
|
if response_body and 'choices' in response_body:
|
||||||
|
for choice in response_body['choices']:
|
||||||
|
assistant_msg = {
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': choice.get('message', {}).get('content', '')
|
||||||
|
}
|
||||||
|
if reasoning_content:
|
||||||
|
assistant_msg['reasoning'] = reasoning_content
|
||||||
|
messages.append(assistant_msg)
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def get_all_conversations(self) -> List[Dict[str, Any]]:
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT conversation_id, created_at, updated_at
|
||||||
|
FROM conversations
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'conversation_id': row['conversation_id'],
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
'updated_at': row['updated_at']
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def export_to_jsonl(self, output_path: str, include_reasoning: bool = True) -> int:
|
||||||
|
conversations = self.get_all_conversations()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
for conv in conversations:
|
||||||
|
messages = self.get_conversation_messages(conv['conversation_id'])
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not include_reasoning:
|
||||||
|
messages = [
|
||||||
|
{k: v for k, v in msg.items() if k != 'reasoning'}
|
||||||
|
for msg in messages
|
||||||
|
]
|
||||||
|
|
||||||
|
jsonl_line = json.dumps({'messages': messages}, ensure_ascii=False)
|
||||||
|
f.write(jsonl_line + '\n')
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM conversations")
|
||||||
|
conversation_count = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM requests")
|
||||||
|
request_count = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM responses")
|
||||||
|
response_count = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
cursor.execute("SELECT SUM(tokens_used) as total FROM responses")
|
||||||
|
total_tokens = cursor.fetchone()['total'] or 0
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'conversations': conversation_count,
|
||||||
|
'requests': request_count,
|
||||||
|
'responses': response_count,
|
||||||
|
'total_tokens': total_tokens
|
||||||
|
}
|
||||||
5
export.bat
Normal file
5
export.bat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
echo Exporting LLM training data...
|
||||||
|
echo.
|
||||||
|
python export.py %*
|
||||||
|
pause
|
||||||
93
export.py
Normal file
93
export.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from database import LLMDatabase
|
||||||
|
from proxy_addon import load_config
|
||||||
|
|
||||||
|
|
||||||
|
def export_training_data(output_path: str, db_path: str = "llm_data.db",
|
||||||
|
include_reasoning: bool = True) -> int:
|
||||||
|
db = LLMDatabase(db_path)
|
||||||
|
count = db.export_to_jsonl(output_path, include_reasoning)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def export_with_metadata(output_path: str, db_path: str = "llm_data.db") -> int:
|
||||||
|
db = LLMDatabase(db_path)
|
||||||
|
conversations = db.get_all_conversations()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
for conv in conversations:
|
||||||
|
messages = db.get_conversation_messages(conv['conversation_id'])
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'messages': messages,
|
||||||
|
'metadata': {
|
||||||
|
'conversation_id': conv['conversation_id'],
|
||||||
|
'created_at': conv['created_at'],
|
||||||
|
'updated_at': conv['updated_at']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonl_line = json.dumps(data, ensure_ascii=False)
|
||||||
|
f.write(jsonl_line + '\n')
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config = load_config()
|
||||||
|
export_config = config.get('export', {})
|
||||||
|
db_config = config.get('database', {})
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Export LLM training data to JSONL format')
|
||||||
|
parser.add_argument('--output', '-o', type=str,
|
||||||
|
default=f"exports/training_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl",
|
||||||
|
help='Output file path')
|
||||||
|
parser.add_argument('--db', type=str, default=db_config.get('path', 'llm_data.db'),
|
||||||
|
help='Database file path')
|
||||||
|
parser.add_argument('--no-reasoning', action='store_true',
|
||||||
|
help='Exclude reasoning content from export')
|
||||||
|
parser.add_argument('--with-metadata', action='store_true',
|
||||||
|
help='Include metadata in export')
|
||||||
|
parser.add_argument('--stats', action='store_true',
|
||||||
|
help='Show database statistics')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.stats:
|
||||||
|
db = LLMDatabase(args.db)
|
||||||
|
stats = db.get_stats()
|
||||||
|
print("\nDatabase Statistics:")
|
||||||
|
print(f" Conversations: {stats['conversations']}")
|
||||||
|
print(f" Requests: {stats['requests']}")
|
||||||
|
print(f" Responses: {stats['responses']}")
|
||||||
|
print(f" Total Tokens: {stats['total_tokens']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
output_path = Path(args.output)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
include_reasoning = not args.no_reasoning
|
||||||
|
|
||||||
|
if args.with_metadata:
|
||||||
|
count = export_with_metadata(str(output_path), args.db)
|
||||||
|
print(f"\nExported {count} conversations with metadata to: {output_path}")
|
||||||
|
else:
|
||||||
|
count = export_training_data(str(output_path), args.db, include_reasoning)
|
||||||
|
print(f"\nExported {count} conversations to: {output_path}")
|
||||||
|
|
||||||
|
if include_reasoning:
|
||||||
|
print(" (Reasoning content included)")
|
||||||
|
else:
|
||||||
|
print(" (Reasoning content excluded)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
288
proxy_addon.py
Normal file
288
proxy_addon.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from mitmproxy import http
|
||||||
|
from database import LLMDatabase
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProxyAddon:
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
self.config = config
|
||||||
|
self.db = LLMDatabase(config['database']['path'])
|
||||||
|
self.path_patterns = config['filter'].get('path_patterns', ['/v1/'])
|
||||||
|
self.host_patterns = config['filter'].get('host_patterns', [])
|
||||||
|
self.save_all = config['filter'].get('save_all_requests', False)
|
||||||
|
logger.info("LLMProxyAddon initialized")
|
||||||
|
|
||||||
|
def is_llm_request(self, flow: http.HTTPFlow) -> bool:
|
||||||
|
path = flow.request.path
|
||||||
|
host = flow.request.host
|
||||||
|
|
||||||
|
if host.startswith("clerk.openrouter.ai"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for pattern in self.path_patterns:
|
||||||
|
if pattern in path:
|
||||||
|
logger.info(f"LLM path match: host={host}, path={path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
for pattern in self.host_patterns:
|
||||||
|
if pattern in host:
|
||||||
|
logger.info(f"LLM host match: host={host}, path={path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_conversation_id(self, request_body: Dict[str, Any]) -> Optional[str]:
|
||||||
|
if 'conversation_id' in request_body:
|
||||||
|
return request_body['conversation_id']
|
||||||
|
|
||||||
|
messages = request_body.get('messages', [])
|
||||||
|
if messages and len(messages) > 0:
|
||||||
|
first_msg = messages[0]
|
||||||
|
if 'conversation_id' in first_msg:
|
||||||
|
return first_msg['conversation_id']
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
system_content = None
|
||||||
|
first_user_content = None
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get('role')
|
||||||
|
if role == 'system' and system_content is None:
|
||||||
|
system_content = msg.get('content', '')
|
||||||
|
if role == 'user' and first_user_content is None:
|
||||||
|
first_user_content = msg.get('content', '')
|
||||||
|
if system_content is not None and first_user_content is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if first_user_content is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = (system_content or '') + '\n---\n' + first_user_content
|
||||||
|
conv_id = uuid.uuid5(uuid.NAMESPACE_URL, key)
|
||||||
|
return str(conv_id)
|
||||||
|
|
||||||
|
def extract_reasoning(self, response_body: Dict[str, Any]) -> Optional[str]:
|
||||||
|
reasoning = None
|
||||||
|
|
||||||
|
if 'choices' in response_body:
|
||||||
|
for choice in response_body['choices']:
|
||||||
|
message = choice.get('message', {})
|
||||||
|
if 'reasoning_content' in message:
|
||||||
|
reasoning = message['reasoning_content']
|
||||||
|
break
|
||||||
|
if 'reasoning' in message:
|
||||||
|
reasoning = message['reasoning']
|
||||||
|
break
|
||||||
|
|
||||||
|
if 'reasoning_content' in response_body:
|
||||||
|
reasoning = response_body['reasoning_content']
|
||||||
|
|
||||||
|
if 'reasoning' in response_body:
|
||||||
|
reasoning = response_body['reasoning']
|
||||||
|
|
||||||
|
return reasoning
|
||||||
|
|
||||||
|
def extract_tokens_used(self, response_body: Dict[str, Any]) -> Optional[int]:
|
||||||
|
usage = response_body.get('usage', {})
|
||||||
|
if usage:
|
||||||
|
total_tokens = usage.get('total_tokens')
|
||||||
|
if total_tokens is not None:
|
||||||
|
return total_tokens
|
||||||
|
|
||||||
|
prompt_tokens = usage.get('prompt_tokens', 0)
|
||||||
|
completion_tokens = usage.get('completion_tokens', 0)
|
||||||
|
return prompt_tokens + completion_tokens
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_sse_response(self, raw_content: bytes) -> Optional[Dict[str, Any]]:
|
||||||
|
text = raw_content.decode('utf-8', errors='ignore')
|
||||||
|
lines = text.splitlines()
|
||||||
|
data_lines = []
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith(':'):
|
||||||
|
continue
|
||||||
|
if not line.startswith('data:'):
|
||||||
|
continue
|
||||||
|
payload = line[5:].strip()
|
||||||
|
if payload == '[DONE]':
|
||||||
|
break
|
||||||
|
data_lines.append(payload)
|
||||||
|
if not data_lines:
|
||||||
|
return None
|
||||||
|
content_parts = []
|
||||||
|
reasoning_parts = []
|
||||||
|
tool_calls_state: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for payload in data_lines:
|
||||||
|
try:
|
||||||
|
obj = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
choices = obj.get('choices', [])
|
||||||
|
for choice in choices:
|
||||||
|
delta = choice.get('delta') or choice.get('message') or {}
|
||||||
|
if 'reasoning_content' in delta:
|
||||||
|
reasoning_parts.append(delta.get('reasoning_content') or '')
|
||||||
|
if 'content' in delta:
|
||||||
|
content_parts.append(delta.get('content') or '')
|
||||||
|
if 'tool_calls' in delta:
|
||||||
|
for idx, tc in enumerate(delta.get('tool_calls') or []):
|
||||||
|
tc_id = tc.get('id') or str(idx)
|
||||||
|
state = tool_calls_state.get(tc_id)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
'id': tc.get('id'),
|
||||||
|
'type': tc.get('type'),
|
||||||
|
'function': {
|
||||||
|
'name': None,
|
||||||
|
'arguments': ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tool_calls_state[tc_id] = state
|
||||||
|
fn = tc.get('function') or {}
|
||||||
|
if fn.get('name'):
|
||||||
|
state['function']['name'] = fn['name']
|
||||||
|
if fn.get('arguments'):
|
||||||
|
state['function']['arguments'] = state['function']['arguments'] + fn['arguments']
|
||||||
|
message: Dict[str, Any] = {}
|
||||||
|
if content_parts:
|
||||||
|
message['content'] = ''.join(content_parts)
|
||||||
|
if reasoning_parts:
|
||||||
|
message['reasoning_content'] = ''.join(reasoning_parts)
|
||||||
|
if tool_calls_state:
|
||||||
|
message['tool_calls'] = list(tool_calls_state.values())
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'message': message
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_valid_llm_request(self, request_body: Dict[str, Any]) -> bool:
|
||||||
|
if 'messages' in request_body:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if 'prompt' in request_body:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if 'input' in request_body:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def request(self, flow: http.HTTPFlow) -> None:
|
||||||
|
if not self.is_llm_request(flow):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Processing potential LLM request: {flow.request.method} {flow.request.host}{flow.request.path}")
|
||||||
|
request_body = json.loads(flow.request.content)
|
||||||
|
|
||||||
|
if not self.is_valid_llm_request(request_body):
|
||||||
|
return
|
||||||
|
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
model = request_body.get('model', 'unknown')
|
||||||
|
messages = request_body.get('messages', [])
|
||||||
|
conversation_id = self.extract_conversation_id(request_body)
|
||||||
|
|
||||||
|
flow.request_id = request_id
|
||||||
|
|
||||||
|
self.db.save_request(
|
||||||
|
request_id=request_id,
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
request_body=request_body,
|
||||||
|
conversation_id=conversation_id
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = f"\033[94mSaved request: {request_id}, model: {model}, messages: {len(messages)}\033[0m"
|
||||||
|
logger.info(msg)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err = f"Failed to parse LLM request body for {flow.request.method} {flow.request.path}"
|
||||||
|
logger.error(err)
|
||||||
|
except Exception as e:
|
||||||
|
err = f"Error processing request: {e}"
|
||||||
|
logger.error(err)
|
||||||
|
|
||||||
|
def response(self, flow: http.HTTPFlow) -> None:
|
||||||
|
if not hasattr(flow, 'request_id'):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = flow.response.content
|
||||||
|
content_type = flow.response.headers.get('content-type', '')
|
||||||
|
response_body: Optional[Dict[str, Any]] = None
|
||||||
|
if 'text/event-stream' in content_type or raw.strip().startswith(b'data:'):
|
||||||
|
response_body = self.parse_sse_response(raw)
|
||||||
|
else:
|
||||||
|
response_body = json.loads(raw)
|
||||||
|
if not response_body:
|
||||||
|
return
|
||||||
|
|
||||||
|
reasoning_content = self.extract_reasoning(response_body)
|
||||||
|
tokens_used = self.extract_tokens_used(response_body)
|
||||||
|
|
||||||
|
self.db.save_response(
|
||||||
|
request_id=flow.request_id,
|
||||||
|
response_body=response_body,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
|
tokens_used=tokens_used
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = f"\033[94mSaved response for request: {flow.request_id}, tokens: {tokens_used}\033[0m"
|
||||||
|
logger.info(msg)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err = f"Failed to parse response body for {flow.request.path}"
|
||||||
|
logger.debug(err)
|
||||||
|
except Exception as e:
|
||||||
|
err = f"Error processing response: {e}"
|
||||||
|
logger.error(err)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = "config.json") -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning(f"Config file not found: {config_path}, using defaults")
|
||||||
|
return {
|
||||||
|
"proxy": {
|
||||||
|
"listen_port": 8080,
|
||||||
|
"listen_host": "127.0.0.1"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"path": "llm_data.db"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"enabled": True,
|
||||||
|
"path_patterns": ["/v1/", "/chat/completions", "/completions"],
|
||||||
|
"host_patterns": ["deepseek.com", "openrouter.ai", "api.openai.com"],
|
||||||
|
"save_all_requests": False
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"output_dir": "exports",
|
||||||
|
"include_reasoning": True,
|
||||||
|
"include_metadata": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
addons = [LLMProxyAddon(config)]
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mitmproxy>=10.0.0
|
||||||
5
start.bat
Normal file
5
start.bat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
echo Starting LLM Proxy Server...
|
||||||
|
echo.
|
||||||
|
python start_proxy.py
|
||||||
|
pause
|
||||||
111
start_proxy.py
Normal file
111
start_proxy.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import platform
|
||||||
|
import ctypes
|
||||||
|
import winreg
|
||||||
|
from mitmproxy.tools.main import mitmdump
|
||||||
|
from proxy_addon import load_config
|
||||||
|
|
||||||
|
|
||||||
|
class SystemProxyManager:
|
||||||
|
def __init__(self, host: str, port: int):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.original_enable = None
|
||||||
|
self.original_server = None
|
||||||
|
|
||||||
|
def _apply_windows_internet_options(self):
|
||||||
|
option_refresh = 37
|
||||||
|
option_settings_changed = 39
|
||||||
|
internet_set_option = ctypes.windll.Wininet.InternetSetOptionW
|
||||||
|
internet_set_option(0, option_settings_changed, 0, 0)
|
||||||
|
internet_set_option(0, option_refresh, 0, 0)
|
||||||
|
|
||||||
|
def enable(self):
|
||||||
|
if platform.system().lower() != "windows":
|
||||||
|
return
|
||||||
|
key_path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
||||||
|
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_READ | winreg.KEY_WRITE)
|
||||||
|
try:
|
||||||
|
self.original_enable, _ = winreg.QueryValueEx(key, "ProxyEnable")
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.original_enable = 0
|
||||||
|
try:
|
||||||
|
self.original_server, _ = winreg.QueryValueEx(key, "ProxyServer")
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.original_server = ""
|
||||||
|
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
|
||||||
|
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, f"{self.host}:{self.port}")
|
||||||
|
winreg.CloseKey(key)
|
||||||
|
self._apply_windows_internet_options()
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
if platform.system().lower() != "windows":
|
||||||
|
return
|
||||||
|
if self.original_enable is None or self.original_server is None:
|
||||||
|
return
|
||||||
|
key_path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
||||||
|
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_READ | winreg.KEY_WRITE)
|
||||||
|
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, self.original_enable)
|
||||||
|
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, self.original_server)
|
||||||
|
winreg.CloseKey(key)
|
||||||
|
self._apply_windows_internet_options()
|
||||||
|
|
||||||
|
|
||||||
|
def start_proxy(config_path: str = "config.json", port: int = 8080, host: str = "127.0.0.1", enable_system_proxy: bool = True):
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
proxy_config = config.get('proxy', {})
|
||||||
|
listen_port = port or proxy_config.get('listen_port', 8080)
|
||||||
|
listen_host = host or proxy_config.get('listen_host', '127.0.0.1')
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"LLM Proxy Server")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Listening on: {listen_host}:{listen_port}")
|
||||||
|
print(f"Config file: {config_path}")
|
||||||
|
print(f"Database: {config.get('database', {}).get('path', 'llm_data.db')}")
|
||||||
|
if enable_system_proxy and platform.system().lower() == "windows":
|
||||||
|
print("System proxy: enabled for current session")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
manager = None
|
||||||
|
if enable_system_proxy:
|
||||||
|
manager = SystemProxyManager(listen_host, listen_port)
|
||||||
|
manager.enable()
|
||||||
|
|
||||||
|
sys.argv = [
|
||||||
|
'mitmdump',
|
||||||
|
'-q',
|
||||||
|
'-s', 'proxy_addon.py',
|
||||||
|
'--listen-host', listen_host,
|
||||||
|
'--listen-port', str(listen_port),
|
||||||
|
'--set', 'block_global=false',
|
||||||
|
'--set', 'flow_detail=0'
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
mitmdump()
|
||||||
|
finally:
|
||||||
|
if manager is not None:
|
||||||
|
manager.disable()
|
||||||
|
|
||||||
|
|
||||||
|
def cli_main():
|
||||||
|
parser = argparse.ArgumentParser(description='Start LLM Proxy Server')
|
||||||
|
parser.add_argument('--config', '-c', type=str, default='config.json',
|
||||||
|
help='Path to config file')
|
||||||
|
parser.add_argument('--port', '-p', type=int, default=None,
|
||||||
|
help='Listen port (overrides config)')
|
||||||
|
parser.add_argument('--host', '-H', type=str, default=None,
|
||||||
|
help='Listen host (overrides config)')
|
||||||
|
parser.add_argument('--no-system-proxy', action='store_true',
|
||||||
|
help='Do not modify system proxy settings')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
start_proxy(args.config, args.port, args.host, not args.no_system_proxy)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli_main()
|
||||||
Reference in New Issue
Block a user