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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cc2go = router:main
@@ -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.
@@ -0,0 +1,5 @@
1
+ error_handler
2
+ mcp_bypass
3
+ router
4
+ streaming
5
+ tray
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