cc2go 0.7.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cc2go-0.7.3.dist-info/METADATA +194 -0
- cc2go-0.7.3.dist-info/RECORD +11 -0
- cc2go-0.7.3.dist-info/WHEEL +5 -0
- cc2go-0.7.3.dist-info/entry_points.txt +2 -0
- cc2go-0.7.3.dist-info/licenses/LICENSE +21 -0
- cc2go-0.7.3.dist-info/top_level.txt +5 -0
- error_handler.py +150 -0
- mcp_bypass.py +176 -0
- router.py +1349 -0
- streaming.py +184 -0
- tray.py +209 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cc2go
|
|
3
|
+
Version: 0.7.3
|
|
4
|
+
Summary: Claude Code to OpenCode Go adapter — route Anthropic format to OpenAI models
|
|
5
|
+
Author: lzg14
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lzg14/cc2go
|
|
8
|
+
Project-URL: Source, https://github.com/lzg14/cc2go
|
|
9
|
+
Project-URL: BugTracker, https://github.com/lzg14/cc2go/issues
|
|
10
|
+
Keywords: claude-code,opencode,llm-proxy,ai-adapter,anthropic,openai
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: fastapi
|
|
26
|
+
Requires-Dist: uvicorn
|
|
27
|
+
Requires-Dist: httpx
|
|
28
|
+
Requires-Dist: python-dotenv
|
|
29
|
+
Requires-Dist: pystray>=0.19.0
|
|
30
|
+
Requires-Dist: Pillow>=10.0.0
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# cc2go <small>v0.7.3</small>
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<b>Claude Code → OpenCode Go Adapter</b>
|
|
37
|
+
<br>
|
|
38
|
+
A lightweight proxy that lets Claude Code use any OpenAI-compatible model
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python">
|
|
43
|
+
<img src="https://img.shields.io/github/license/lzg14/cc2go" alt="License">
|
|
44
|
+
<img src="https://img.shields.io/github/v/release/lzg14/cc2go" alt="Release">
|
|
45
|
+
<img src="https://img.shields.io/github/stars/lzg14/cc2go" alt="Stars">
|
|
46
|
+
<img src="https://img.shields.io/github/actions/workflow/status/lzg14/cc2go/ci.yml?branch=master" alt="CI">
|
|
47
|
+
<img src="https://img.shields.io/pypi/v/cc2go" alt="PyPI">
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<p align="center">
|
|
51
|
+
<img src="static/screenshot.png" alt="cc2go Web UI" width="800">
|
|
52
|
+
<br>
|
|
53
|
+
<em>Web admin page — switch models, add endpoints, view logs</em>
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
## Features
|
|
57
|
+
|
|
58
|
+
- 🔄 **Protocol translation** — Converts Anthropic Messages API ↔ OpenAI Chat Completions
|
|
59
|
+
- 🌐 **Web UI** — Built-in admin page at `http://localhost:4001`, click to switch models
|
|
60
|
+
- 🎯 **Model switching** — Click to switch models, auto-syncs to Claude Code settings
|
|
61
|
+
- ➕ **Custom models** — Add your own endpoints with independent API keys and URLs
|
|
62
|
+
- 🖼️ **Image support** — Converts Anthropic image blocks to OpenAI image_url format
|
|
63
|
+
- ⚡ **Streaming** — Real-time SSE streaming conversion (OpenAI → Anthropic format)
|
|
64
|
+
- 🔁 **Adaptive retry** — Error classification with exponential backoff, max 3 retries
|
|
65
|
+
- 📋 **Log management** — Built-in log viewer with rotation (5MB per file, 3 backups)
|
|
66
|
+
- 🖥️ **System tray** — Tray icon for opening admin page and quitting; auto-opens admin on start
|
|
67
|
+
- 💰 **Token saving** — Strips `<system-reminder>`, `[思考过程]` reasoning, and `thinking` blocks before forwarding upstream
|
|
68
|
+
- 💾 **Config backup** — Auto-backup original Claude Code config on first model switch; one-click restore from admin UI
|
|
69
|
+
- 🔑 **Security** — Defaults to 127.0.0.1 (local-only), Bearer Token authentication on proxy endpoints
|
|
70
|
+
- 🔧 **Tool name sanitization** — Replaces special characters in tool names to prevent 400 errors
|
|
71
|
+
- 🧹 **Schema cleaning** — Recursively removes incompatible JSON Schema fields for broader model compatibility
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Getting Started (For Non-Technical Users)
|
|
76
|
+
|
|
77
|
+
### Step 1: Download
|
|
78
|
+
|
|
79
|
+
Download the latest release from [GitHub Releases](https://github.com/lzg14/cc2go/releases).
|
|
80
|
+
Extract the ZIP file to any folder (not Program Files).
|
|
81
|
+
|
|
82
|
+
### Step 2: Configure API Key
|
|
83
|
+
|
|
84
|
+
1. Open the extracted folder
|
|
85
|
+
2. Copy `.env.example` to `.env` (or create a new file named `.env`)
|
|
86
|
+
3. Open `.env` with a text editor (like Notepad) and add your OpenCode Go API key:
|
|
87
|
+
```
|
|
88
|
+
OPENCODE_API_KEY=your_api_key_here
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Step 3: Run
|
|
92
|
+
|
|
93
|
+
Double-click `start_bg.bat` in the folder.
|
|
94
|
+
|
|
95
|
+
A system tray icon will appear. The admin page will open automatically in your browser.
|
|
96
|
+
|
|
97
|
+
### Step 4: Open in Claude Code
|
|
98
|
+
|
|
99
|
+
In Claude Code's settings, configure:
|
|
100
|
+
|
|
101
|
+
| Setting | Value |
|
|
102
|
+
|---------|-------|
|
|
103
|
+
| Base URL | `http://localhost:4001` |
|
|
104
|
+
| API Key | `sk-cc2go-local` |
|
|
105
|
+
|
|
106
|
+
That's it! Use Claude Code as normal — select models from the web admin page at `http://localhost:4001`.
|
|
107
|
+
|
|
108
|
+
### How to Quit
|
|
109
|
+
|
|
110
|
+
Right-click the system tray icon → click "Exit".
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## For Developers
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pip install -r requirements.txt
|
|
118
|
+
cp .env.example .env # Edit .env with your API key
|
|
119
|
+
python src/router.py
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Scripts (Windows)
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
scripts\start_bg.bat # Background mode (system tray, no terminal)
|
|
126
|
+
scripts\stop.bat # Stop background process
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Docker
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
docker build -t cc2go .
|
|
135
|
+
docker run -d -p 4001:4001 --env-file .env cc2go
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Access at `http://localhost:4001`.
|
|
139
|
+
|
|
140
|
+
## Linux Service (systemd)
|
|
141
|
+
|
|
142
|
+
For permanent deployment on Linux:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Copy service file
|
|
146
|
+
sudo cp cc2go.service /etc/systemd/system/
|
|
147
|
+
sudo systemctl daemon-reload
|
|
148
|
+
sudo systemctl enable cc2go
|
|
149
|
+
sudo systemctl start cc2go
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Requires a `cc2go` user and `/opt/cc2go` installation directory.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## API Endpoints
|
|
157
|
+
|
|
158
|
+
| Endpoint | Method | Description |
|
|
159
|
+
|----------|--------|-------------|
|
|
160
|
+
| `/` | GET | Web admin UI |
|
|
161
|
+
| `/v1/messages` | POST | Claude format entry (Anthropic → OpenAI) |
|
|
162
|
+
| `/v1/chat/completions` | POST | OpenAI format passthrough |
|
|
163
|
+
| `/v1/models` | GET | List available models |
|
|
164
|
+
| `/health` | GET | Health check |
|
|
165
|
+
| `/api/config` | GET/PUT | Configuration management |
|
|
166
|
+
| `/api/custom-models` | GET/PUT | Custom model management |
|
|
167
|
+
| `/api/logs` | GET | Recent log entries |
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Custom Model Routing
|
|
172
|
+
|
|
173
|
+
| Endpoint | Behavior |
|
|
174
|
+
|----------|----------|
|
|
175
|
+
| `/v1/messages` | Anthropic format passthrough (no conversion, thinking blocks preserved) |
|
|
176
|
+
| `/v1/chat/completions` | OpenAI format conversion (tool_calls gets reasoning_content="" if missing) |
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Configuration
|
|
181
|
+
|
|
182
|
+
All configuration via Web UI (`http://localhost:4001`):
|
|
183
|
+
- **Connection** — OpenCode Go base URL and API key
|
|
184
|
+
- **Service** — Host, port, master key (auto-syncs to Claude Code)
|
|
185
|
+
- **Custom Models** — Add/edit/remove custom model endpoints
|
|
186
|
+
- **Logs** — View logs, set log level, toggle detailed logging
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
|
193
|
+
|
|
194
|
+
> Technical details in [ARCHITECTURE.md](ARCHITECTURE.md)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
error_handler.py,sha256=-96Xz4YbxlnIpIo3QFp7zkA_WRYMHX-vm2OGqsP9_i8,4854
|
|
2
|
+
mcp_bypass.py,sha256=S8JQuNu7K24d-Fk2Vdl07uNsL5EsjHEEYQn5M6KqcZU,7015
|
|
3
|
+
router.py,sha256=3RLgkAlEGY8dopbLBnnuIovKhTfjfg-XNi1-yTKUX4A,54950
|
|
4
|
+
streaming.py,sha256=UqY49q-Z-OoikOSLdj5hZK71KLSbK4ezr2WOF7bMspM,6822
|
|
5
|
+
tray.py,sha256=1xrXQf9yNEuzLcammlLJydIW56SGR-fc9FZoNU5dNbM,5825
|
|
6
|
+
cc2go-0.7.3.dist-info/licenses/LICENSE,sha256=uAClLHfIrJJKFrvTQTb-KHf0d--sHqoa968MNnA9iWY,1077
|
|
7
|
+
cc2go-0.7.3.dist-info/METADATA,sha256=qaZYe-Zy6PATGyzzf-TiPcFeE_bPmosDVZ6j6qc4zzc,6597
|
|
8
|
+
cc2go-0.7.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
cc2go-0.7.3.dist-info/entry_points.txt,sha256=A4BMVruTUktAGpSWnJjHgrm5lJS5BetQ4bYM42gStBo,38
|
|
10
|
+
cc2go-0.7.3.dist-info/top_level.txt,sha256=54MV57l6iulpEPmpuR9EcDNEKNnNebv1TePNnwY7tCI,47
|
|
11
|
+
cc2go-0.7.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
error_handler.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
错误自适应处理器
|
|
3
|
+
提供错误分类、指数退避重试、模型切换 fallback
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Tuple, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("llm_router")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ErrorType(Enum):
|
|
18
|
+
RATE_LIMIT = "rate_limit" # 429
|
|
19
|
+
SERVER_ERROR = "server_error" # 500/502/503
|
|
20
|
+
AUTH_ERROR = "auth_error" # 401/403
|
|
21
|
+
CLIENT_ERROR = "client_error" # 400
|
|
22
|
+
UNKNOWN = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RetryStrategy(Enum):
|
|
26
|
+
RETRY_WITH_BACKOFF = "retry_with_backoff" # 指数退避重试
|
|
27
|
+
SWITCH_MODEL = "switch_model" # 切换模型
|
|
28
|
+
FAIL_FAST = "fail_fast" # 直接失败
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify_error(status_code: int) -> ErrorType:
|
|
32
|
+
"""根据状态码分类错误类型"""
|
|
33
|
+
if status_code == 429:
|
|
34
|
+
return ErrorType.RATE_LIMIT
|
|
35
|
+
elif status_code in (500, 502, 503, 504):
|
|
36
|
+
return ErrorType.SERVER_ERROR
|
|
37
|
+
elif status_code in (401, 403):
|
|
38
|
+
return ErrorType.AUTH_ERROR
|
|
39
|
+
elif status_code == 400:
|
|
40
|
+
return ErrorType.CLIENT_ERROR
|
|
41
|
+
else:
|
|
42
|
+
return ErrorType.UNKNOWN
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_backoff_delay(attempt: int, base: float = 1.0, max_delay: float = 60.0, jitter: bool = True) -> float:
|
|
46
|
+
"""计算指数退避延迟"""
|
|
47
|
+
delay = base * (2 ** attempt)
|
|
48
|
+
delay = min(delay, max_delay)
|
|
49
|
+
if jitter:
|
|
50
|
+
delay = delay * (0.5 + random.random() * 0.5)
|
|
51
|
+
return delay
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_upstream_error(response_body) -> str:
|
|
55
|
+
"""从上游响应中提取人类可读的错误信息"""
|
|
56
|
+
if isinstance(response_body, dict):
|
|
57
|
+
if "error" in response_body:
|
|
58
|
+
return response_body["error"].get("message", str(response_body["error"]))
|
|
59
|
+
if "message" in response_body:
|
|
60
|
+
return response_body["message"]
|
|
61
|
+
return str(response_body)
|
|
62
|
+
elif isinstance(response_body, str):
|
|
63
|
+
try:
|
|
64
|
+
return parse_upstream_error(json.loads(response_body))
|
|
65
|
+
except Exception:
|
|
66
|
+
return response_body[:500]
|
|
67
|
+
return "Unknown error"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def classify_and_suggest_action(
|
|
71
|
+
status_code: int,
|
|
72
|
+
response_body,
|
|
73
|
+
attempt: int,
|
|
74
|
+
max_retry: int
|
|
75
|
+
) -> Tuple[RetryStrategy, str, Optional[str]]:
|
|
76
|
+
"""
|
|
77
|
+
分析错误并建议处理动作
|
|
78
|
+
Returns: (strategy, log_message, fallback_hint)
|
|
79
|
+
fallback_hint: None | "try_next_available"
|
|
80
|
+
"""
|
|
81
|
+
error_type = classify_error(status_code)
|
|
82
|
+
error_msg = parse_upstream_error(response_body)
|
|
83
|
+
|
|
84
|
+
if error_type == ErrorType.RATE_LIMIT:
|
|
85
|
+
if attempt < max_retry - 1:
|
|
86
|
+
delay = get_backoff_delay(attempt)
|
|
87
|
+
return (
|
|
88
|
+
RetryStrategy.RETRY_WITH_BACKOFF,
|
|
89
|
+
f"[RateLimit] 429, 退避 {delay:.1f}s 后重试 (attempt {attempt + 1}/{max_retry})",
|
|
90
|
+
None
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
return (
|
|
94
|
+
RetryStrategy.SWITCH_MODEL,
|
|
95
|
+
"[RateLimit] 多次 429,建议切换模型",
|
|
96
|
+
"try_next_available"
|
|
97
|
+
)
|
|
98
|
+
elif error_type == ErrorType.SERVER_ERROR:
|
|
99
|
+
if attempt < max_retry - 1:
|
|
100
|
+
delay = get_backoff_delay(attempt, base=2.0)
|
|
101
|
+
return (
|
|
102
|
+
RetryStrategy.RETRY_WITH_BACKOFF,
|
|
103
|
+
f"[ServerError] {status_code}, 退避 {delay:.1f}s 后重试 (attempt {attempt + 1}/{max_retry})",
|
|
104
|
+
None
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
return (
|
|
108
|
+
RetryStrategy.SWITCH_MODEL,
|
|
109
|
+
f"[ServerError] 多次 {status_code},建议切换模型",
|
|
110
|
+
"try_next_available"
|
|
111
|
+
)
|
|
112
|
+
elif error_type == ErrorType.AUTH_ERROR:
|
|
113
|
+
return (
|
|
114
|
+
RetryStrategy.FAIL_FAST,
|
|
115
|
+
f"[AuthError] {status_code} — 认证失败,请检查 API Key 配置",
|
|
116
|
+
None
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
return (
|
|
120
|
+
RetryStrategy.FAIL_FAST,
|
|
121
|
+
f"[Error] {status_code}: {error_msg[:200]}",
|
|
122
|
+
None
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ============ 归档限速 ============
|
|
127
|
+
class ErrorArchiveRateLimiter:
|
|
128
|
+
"""错误归档限速:window_seconds 内最多归档 1 次"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, window_seconds: float = 30.0):
|
|
131
|
+
self.window = window_seconds
|
|
132
|
+
self._last_archive: float = 0.0
|
|
133
|
+
self._lock = threading.Lock()
|
|
134
|
+
|
|
135
|
+
def update(self, window_seconds: float):
|
|
136
|
+
with self._lock:
|
|
137
|
+
self.window = window_seconds
|
|
138
|
+
|
|
139
|
+
def archive(self) -> bool:
|
|
140
|
+
now = time.time()
|
|
141
|
+
with self._lock:
|
|
142
|
+
if now - self._last_archive >= self.window:
|
|
143
|
+
self._last_archive = now
|
|
144
|
+
return True
|
|
145
|
+
logger.debug("[ArchiveRateLimit] 限速跳过归档")
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# 模块级限速器
|
|
150
|
+
_archive_limiter = ErrorArchiveRateLimiter()
|
mcp_bypass.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP 工具短路模块
|
|
3
|
+
检测特定工具调用并直接处理,避免绕道 LLM 后端
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("llm_router")
|
|
16
|
+
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
MMX_PATH = shutil.which("mmx") or shutil.which("mmx.cmd") or "mmx"
|
|
19
|
+
else:
|
|
20
|
+
MMX_PATH = "mmx"
|
|
21
|
+
|
|
22
|
+
# 可短路的工具及其处理器
|
|
23
|
+
BYPASS_TOOLS = {
|
|
24
|
+
"web_search": {"type": "mmx", "args": ["search", "query"]},
|
|
25
|
+
"mcp__MiniMax__web_search": {"type": "mmx", "args": ["search", "query"]},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def should_bypass(body: Dict) -> Tuple[bool, Optional[str]]:
|
|
30
|
+
"""
|
|
31
|
+
判断请求是否应短路
|
|
32
|
+
仅当消息中模型已实际调用了 bypass 工具(tool_use)才触发,
|
|
33
|
+
不因 tools 参数中有该工具定义就短路。
|
|
34
|
+
Returns: (should_bypass, tool_name)
|
|
35
|
+
"""
|
|
36
|
+
messages = body.get("messages", [])
|
|
37
|
+
if not messages:
|
|
38
|
+
logger.debug("[Bypass] no messages, skip")
|
|
39
|
+
return False, None
|
|
40
|
+
|
|
41
|
+
# 详细调试日志:打印每条消息的结构
|
|
42
|
+
for i, msg in enumerate(messages):
|
|
43
|
+
role = msg.get("role", "?")
|
|
44
|
+
content = msg.get("content", [])
|
|
45
|
+
content_types = []
|
|
46
|
+
if isinstance(content, list):
|
|
47
|
+
content_types = [b.get("type", "?") if isinstance(b, dict) else str(type(b)) for b in content]
|
|
48
|
+
elif isinstance(content, str):
|
|
49
|
+
content_types = ["str"]
|
|
50
|
+
logger.debug(f"[Bypass] msg[{i}] role={role}, content_types={content_types}")
|
|
51
|
+
|
|
52
|
+
if not isinstance(content, list):
|
|
53
|
+
continue
|
|
54
|
+
for j, block in enumerate(content):
|
|
55
|
+
if not isinstance(block, dict):
|
|
56
|
+
continue
|
|
57
|
+
block_type = block.get("type", "?")
|
|
58
|
+
block_name = block.get("name", "")
|
|
59
|
+
logger.debug(f"[Bypass] msg[{i}] block[{j}] type={block_type}, name={repr(block_name)}")
|
|
60
|
+
if block_type in ("tool_use", "tool_use_block"):
|
|
61
|
+
if not block_name:
|
|
62
|
+
logger.debug(f"[Bypass] → tool_use block[{j}] has no name, skip")
|
|
63
|
+
continue
|
|
64
|
+
# MCP 格式: mcp__Provider__tool_name → 归一化到 base
|
|
65
|
+
if block_name.startswith("mcp__"):
|
|
66
|
+
base = block_name.split("__", 2)[-1]
|
|
67
|
+
logger.debug(f"[Bypass] → MCP format, base={base}, BYPASS_TOOLS={list(BYPASS_TOOLS.keys())}")
|
|
68
|
+
if base in BYPASS_TOOLS:
|
|
69
|
+
logger.info(f"[Bypass] HIT! name={block_name} → base={base}")
|
|
70
|
+
return True, base
|
|
71
|
+
if block_name in BYPASS_TOOLS:
|
|
72
|
+
logger.info(f"[Bypass] HIT! name={block_name}")
|
|
73
|
+
return True, block_name
|
|
74
|
+
else:
|
|
75
|
+
logger.debug(f"[Bypass] → tool_use[{j}] name={block_name!r} NOT in BYPASS_TOOLS")
|
|
76
|
+
logger.debug(f"[Bypass] no tool_use found, BYPASS_TOOLS={list(BYPASS_TOOLS.keys())}")
|
|
77
|
+
return False, None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_query(messages: List[Dict]) -> str:
|
|
81
|
+
"""从消息列表中提取用户查询文本"""
|
|
82
|
+
for msg in reversed(messages):
|
|
83
|
+
if msg.get("role") == "user":
|
|
84
|
+
content = msg.get("content", "")
|
|
85
|
+
if isinstance(content, str):
|
|
86
|
+
return content.strip()
|
|
87
|
+
elif isinstance(content, list):
|
|
88
|
+
for part in content:
|
|
89
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
90
|
+
txt = part.get("text", "").strip()
|
|
91
|
+
if txt:
|
|
92
|
+
return txt
|
|
93
|
+
# fallback: 最后一条消息
|
|
94
|
+
if messages:
|
|
95
|
+
content = messages[-1].get("content", "")
|
|
96
|
+
if isinstance(content, str):
|
|
97
|
+
return content.strip()
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def handle_bypass(tool_name: str, query: str) -> Dict:
|
|
102
|
+
"""
|
|
103
|
+
执行短路处理,返回 Anthropic 格式的响应
|
|
104
|
+
"""
|
|
105
|
+
logger.info(f"[Bypass] handle_bypass called: tool={tool_name}, query={query!r}")
|
|
106
|
+
handler = BYPASS_TOOLS.get(tool_name)
|
|
107
|
+
if not handler:
|
|
108
|
+
raise ValueError(f"Unknown bypass tool: {tool_name}")
|
|
109
|
+
|
|
110
|
+
if handler["type"] == "mmx":
|
|
111
|
+
result = await handle_mmx_search(tool_name, query)
|
|
112
|
+
logger.debug(f"[Bypass] mmx result: type={result['type']}, content_len={len(result.get('content',[]))}")
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
raise ValueError(f"Unknown handler type: {handler['type']}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def handle_mmx_search(tool_name: str, query: str) -> Dict:
|
|
119
|
+
"""通过 mmx CLI 执行搜索并返回 Anthropic 格式"""
|
|
120
|
+
logger.info(f"[Bypass/mmx] starting search: query={query!r}, mmx_path={MMX_PATH}")
|
|
121
|
+
result = {
|
|
122
|
+
"type": "message",
|
|
123
|
+
"id": f"bypass-{int(time.time() * 1000)}",
|
|
124
|
+
"role": "assistant",
|
|
125
|
+
"content": [],
|
|
126
|
+
"model": "bypass",
|
|
127
|
+
"stop_reason": "end_turn",
|
|
128
|
+
"usage": {"input_tokens": 0, "output_tokens": 0}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if not query:
|
|
132
|
+
result["content"] = [{"type": "text", "text": "No query provided"}]
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
proc = await asyncio.create_subprocess_exec(
|
|
137
|
+
MMX_PATH, "search", "query", query,
|
|
138
|
+
"--output", "json",
|
|
139
|
+
stdout=subprocess.PIPE,
|
|
140
|
+
stderr=subprocess.PIPE
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
stdout, stderr = await asyncio.wait_for(
|
|
144
|
+
proc.communicate(), timeout=30.0
|
|
145
|
+
)
|
|
146
|
+
except asyncio.TimeoutError:
|
|
147
|
+
proc.kill()
|
|
148
|
+
result["content"] = [{"type": "text", "text": "Search timed out after 30s"}]
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
if proc.returncode != 0:
|
|
152
|
+
err_msg = stderr.decode("utf-8", errors="replace").strip()
|
|
153
|
+
result["content"] = [{"type": "text", "text": f"Search failed: {err_msg}"}]
|
|
154
|
+
else:
|
|
155
|
+
output = stdout.decode("utf-8", errors="replace")
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(output)
|
|
158
|
+
lines = []
|
|
159
|
+
for i, item in enumerate(data.get("organic", [])[:5]):
|
|
160
|
+
title = item.get("title", "")
|
|
161
|
+
link = item.get("link", "")
|
|
162
|
+
snippet = item.get("snippet", "")
|
|
163
|
+
snippet_short = snippet[:100] + "..." if len(snippet) > 100 else snippet
|
|
164
|
+
lines.append(f"{i+1}. {title}\n {link}")
|
|
165
|
+
if snippet_short:
|
|
166
|
+
lines.append(f" {snippet_short}")
|
|
167
|
+
if not lines:
|
|
168
|
+
result["content"] = [{"type": "text", "text": f"搜索 [{query}] 无结果"}]
|
|
169
|
+
else:
|
|
170
|
+
result["content"] = [{"type": "text", "text": f"搜索 [{query}]:\n\n" + "\n\n".join(lines)}]
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
result["content"] = [{"type": "text", "text": output[:2000]}]
|
|
173
|
+
except Exception as e:
|
|
174
|
+
result["content"] = [{"type": "text", "text": f"Search error: {str(e)}"}]
|
|
175
|
+
|
|
176
|
+
return result
|