summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHermes Agent <agent@nousresearch.com>2026-06-26 19:00:22 +0800
committerntzyz <i@ntzyz.io>2026-06-26 19:00:22 +0800
commitde71e451c5f1283d642070ac5028fdbae842b988 (patch)
tree8c1038ed1d4eb35b564a551d7f1a939523bb69d6
feat: MiMo web search plugin for Hermes Agent
Xiaomi MiMo 联网搜索 (server-side web search) provider plugin. - WebSearchProvider subclass calling MiMo Chat Completions API with tools=[{type: web_search, force_search: true}] - Parses url_citation annotations into Hermes standard format - Supports XIAOMI_BASE_URL for Token Plan endpoints - Standalone test_search.py CLI script (no Hermes dependency) - Configurable model, max_keyword, timeout via config.yaml
-rw-r--r--.gitignore8
-rw-r--r--README.md110
-rw-r--r--__init__.py14
-rw-r--r--plugin.yaml9
-rw-r--r--provider.py375
-rw-r--r--test_search.py221
6 files changed, 737 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d497e87
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+__pycache__/
+*.pyc
+*.pyo
+.env
+.venv/
+*.egg-info/
+dist/
+build/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d26e2b2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,110 @@
+# MiMo Web Search Plugin for Hermes Agent
+
+将小米 MiMo 的**联网搜索**(server-side web search)接入 Hermes Agent 的 `web_search` 工具。
+
+MiMo 在服务端执行搜索、浏览网页、提取内容,通过 Chat Completions API 的 `url_citation` 注释返回结构化结果。适合中文搜索场景。
+
+## 快速开始
+
+### 1. 独立使用(无需 Hermes)
+
+```bash
+# 安装依赖
+pip install httpx
+
+# 设置 API Key
+export XIAOMI_API_KEY="your-api-key-here"
+
+# 如果使用 Token Plan(国内端点),还需设置 base URL
+export XIAOMI_BASE_URL="https://token-plan-cn.xiaomimimo.com/v1"
+
+# 搜索
+python3 test_search.py "小米17解锁"
+python3 test_search.py "YU7 GT 价格" --limit 10
+python3 test_search.py "今天天气" --raw # 输出完整 API 响应
+```
+
+### 2. 接入 Hermes Agent
+
+将插件复制到 Hermes 的用户插件目录:
+
+```bash
+cp -r ~/mimo-search-plugin ~/.hermes/plugins/web/mimo
+```
+
+在 `~/.hermes/config.yaml` 中启用:
+
+```yaml
+plugins:
+ enabled:
+ - web-mimo
+
+web:
+ search_backend: "mimo"
+```
+
+环境变量(在 `~/.hermes/.env` 中设置):
+
+```
+XIAOMI_API_KEY=your-api-key-here
+# 可选:Token Plan 国内端点
+XIAOMI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
+```
+
+重启 Hermes 后,`web_search` 工具将使用 MiMo 作为搜索后端。
+
+## 获取 API Key
+
+1. 访问 [platform.xiaomimimo.com](https://platform.xiaomimimo.com)
+2. 注册/登录小米账号
+3. 创建 API Key
+4. 在控制台 → 插件管理中**开通联网服务插件**(付费功能)
+
+## 可选配置
+
+在 `~/.hermes/config.yaml` 的 `web.mimo` 下可调整:
+
+```yaml
+web:
+ search_backend: "mimo"
+ mimo:
+ model: "mimo-v2.5" # 可选: mimo-v2.5-pro, mimo-v2.5, mimo-v2-flash 等
+ max_keyword: 3 # 每轮搜索最大关键词数(影响调用次数和费用)
+ timeout: 30 # 请求超时秒数
+```
+
+## 计费
+
+- **联网搜索工具**:国内 ¥16/1000 次,海外 $5/1000 次
+- **Token 消耗**:搜索结果拼入 prompt,按模型输入 token 标准价计费
+- 每轮搜索可能触发 `max_keyword` 个关键词,每个关键词算一次调用
+
+## 工作原理
+
+```
+Hermes web_search 工具
+ ↓
+MiMoWebSearchProvider.search(query, limit)
+ ↓
+POST {base_url}/chat/completions
+ tools: [{type: "web_search", force_search: true, ...}]
+ ↓
+MiMo 服务端搜索 → 返回 annotations: [{type: "url_citation", ...}]
+ ↓
+解析为 Hermes 标准格式: {title, url, description, position}
+```
+
+## 文件说明
+
+| 文件 | 说明 |
+|---|---|
+| `provider.py` | `WebSearchProvider` 子类,核心搜索逻辑 |
+| `__init__.py` | 插件注册入口 |
+| `plugin.yaml` | Hermes 插件清单 |
+| `test_search.py` | 独立测试脚本(可脱离 Hermes 使用) |
+
+## API 参考
+
+- [MiMo 联网搜索文档](https://mimo.mi.com/docs/zh-CN/guides/text-generation/web-search)
+- [MiMo 平台](https://platform.xiaomimimo.com)
+- [Hermes Web Search Provider 插件开发指南](https://hermes-agent.nousresearch.com/docs/developer-guide/web-search-provider-plugin)
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..c1cd62d
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,14 @@
+"""Xiaomi MiMo web search plugin.
+
+Mirrors the ``plugins/web/brave_free/`` layout: ``provider.py`` holds the
+provider class, ``__init__.py::register(ctx)`` registers an instance.
+"""
+
+from __future__ import annotations
+
+from plugins.web.mimo.provider import MiMoWebSearchProvider
+
+
+def register(ctx) -> None:
+ """Register the MiMo Web Search provider with the plugin context."""
+ ctx.register_web_search_provider(MiMoWebSearchProvider())
diff --git a/plugin.yaml b/plugin.yaml
new file mode 100644
index 0000000..a7e620d
--- /dev/null
+++ b/plugin.yaml
@@ -0,0 +1,9 @@
+name: web-mimo
+version: 1.0.0
+description: "Xiaomi MiMo Web Search — server-side web search via MiMo's 联网搜索 tool (Chat Completions API). Requires XIAOMI_API_KEY (https://platform.xiaomimimo.com)."
+author: NousResearch
+kind: backend
+provides_web_providers:
+ - mimo
+requires_env:
+ - XIAOMI_API_KEY
diff --git a/provider.py b/provider.py
new file mode 100644
index 0000000..f7bbb8d
--- /dev/null
+++ b/provider.py
@@ -0,0 +1,375 @@
+"""Xiaomi MiMo Web Search — plugin form.
+
+Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Routes
+``web_search`` tool calls through MiMo's server-side 联网搜索 (web search)
+tool on the Chat Completions API. MiMo performs the actual searching and
+page-browsing server-side; we ask it to return the top results as structured
+JSON so we can hand back the same ``{title, url, description, position}``
+rows every other Hermes web provider produces.
+
+Reference: https://mimo.mi.com/docs/zh-CN/guides/text-generation/web-search
+
+Config keys this provider responds to::
+
+ web:
+ search_backend: "mimo" # explicit per-capability
+ backend: "mimo" # shared fallback
+
+Optional knobs (under ``web.mimo`` in ``config.yaml``)::
+
+ web:
+ mimo:
+ model: "mimo-v2.5" # default model for search queries
+ max_keyword: 3 # max keywords per search round
+ timeout: 30 # seconds (default 30)
+
+Auth env var::
+
+ XIAOMI_API_KEY=... # https://platform.xiaomimimo.com
+ XIAOMI_BASE_URL=... # optional, defaults to https://api.xiaomimimo.com/v1
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from typing import Any, Dict, List, Optional
+
+from agent.web_search_provider import WebSearchProvider
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_MODEL = "mimo-v2.5"
+DEFAULT_TIMEOUT = 30
+DEFAULT_MAX_KEYWORD = 3
+_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1"
+
+# Match JSON block in model output (tolerates leading/trailing prose).
+_JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}", re.MULTILINE)
+
+
+# ---------------------------------------------------------------------------
+# Config
+# ---------------------------------------------------------------------------
+
+
+def _load_mimo_web_config() -> Dict[str, Any]:
+ """Read ``web.mimo`` from config.yaml (returns {} on miss)."""
+ try:
+ from hermes_cli.config import load_config
+
+ cfg = load_config()
+ web_section = cfg.get("web") if isinstance(cfg, dict) else None
+ mimo_section = web_section.get("mimo") if isinstance(web_section, dict) else None
+ return mimo_section if isinstance(mimo_section, dict) else {}
+ except Exception as exc: # noqa: BLE001
+ logger.debug("Could not load web.mimo config: %s", exc)
+ return {}
+
+
+# ---------------------------------------------------------------------------
+# Provider
+# ---------------------------------------------------------------------------
+
+
+class MiMoWebSearchProvider(WebSearchProvider):
+ """Search-only provider backed by Xiaomi MiMo's 联网搜索 tool.
+
+ Sends a structured prompt to MiMo with ``tools=[{"type": "web_search"}]``
+ enabled and asks it to return the top *limit* results as JSON. Falls
+ back to the response ``annotations`` (url_citation entries) if MiMo
+ ignores the JSON schema instruction.
+
+ No extract capability — pair with Firecrawl / Tavily / Exa for
+ ``web_extract`` if you need page content.
+ """
+
+ @property
+ def name(self) -> str:
+ return "mimo"
+
+ @property
+ def display_name(self) -> str:
+ return "Xiaomi MiMo Web Search"
+
+ def is_available(self) -> bool:
+ """Cheap availability probe — env var present."""
+ return bool(os.getenv("XIAOMI_API_KEY", "").strip())
+
+ def supports_search(self) -> bool:
+ return True
+
+ def supports_extract(self) -> bool:
+ return False
+
+ # -- Search -----------------------------------------------------------
+
+ def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
+ """Execute a MiMo-backed web search.
+
+ Returns ``{"success": True, "data": {"web": [{title, url, description, position}, ...]}}``
+ on success, ``{"success": False, "error": str}`` on failure.
+ """
+ try:
+ from tools.interrupt import is_interrupted
+
+ if is_interrupted():
+ return {"success": False, "error": "Interrupted"}
+ except Exception: # noqa: BLE001 — interrupt module is best-effort
+ pass
+
+ api_key = os.getenv("XIAOMI_API_KEY", "").strip()
+ if not api_key:
+ return {
+ "success": False,
+ "error": "XIAOMI_API_KEY is not set. Get one at https://platform.xiaomimimo.com",
+ }
+
+ try:
+ limit = int(limit)
+ except (TypeError, ValueError):
+ limit = 5
+ limit = max(1, min(limit, 20))
+
+ cfg = _load_mimo_web_config()
+ model = cfg.get("model") if isinstance(cfg.get("model"), str) else DEFAULT_MODEL
+ model = model.strip() or DEFAULT_MODEL
+
+ try:
+ timeout = float(cfg.get("timeout", DEFAULT_TIMEOUT))
+ except (TypeError, ValueError):
+ timeout = DEFAULT_TIMEOUT
+
+ base_url = os.getenv("XIAOMI_BASE_URL", "").strip().rstrip("/") or _DEFAULT_BASE_URL
+
+ try:
+ max_keyword = int(cfg.get("max_keyword", DEFAULT_MAX_KEYWORD))
+ except (TypeError, ValueError):
+ max_keyword = DEFAULT_MAX_KEYWORD
+ max_keyword = max(1, min(max_keyword, 10))
+
+ prompt = self._build_prompt(query, limit)
+
+ payload: Dict[str, Any] = {
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ "tools": [
+ {
+ "type": "web_search",
+ "force_search": True,
+ "max_keyword": max_keyword,
+ "limit": max(1, min(limit, 5)),
+ }
+ ],
+ "max_completion_tokens": 1024,
+ "temperature": 1.0,
+ "top_p": 0.95,
+ "stream": False,
+ }
+
+ headers = {
+ "api-key": api_key,
+ "Content-Type": "application/json",
+ }
+
+ try:
+ import httpx
+ except ImportError:
+ return {
+ "success": False,
+ "error": "httpx is not installed (required for MiMo web search)",
+ }
+
+ logger.info(
+ "MiMo web search via %s: '%s' (limit=%d, model=%s, max_keyword=%d)",
+ base_url, query, limit, model, max_keyword,
+ )
+
+ try:
+ resp = httpx.post(
+ f"{base_url}/chat/completions",
+ headers=headers,
+ json=payload,
+ timeout=timeout,
+ )
+ resp.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ status = exc.response.status_code if exc.response is not None else 0
+ body = ""
+ try:
+ body = exc.response.text[:500] if exc.response is not None else ""
+ except Exception:
+ body = ""
+ logger.warning("MiMo web search HTTP %d: %s", status, body)
+ return {
+ "success": False,
+ "error": f"MiMo web search returned HTTP {status}: {body}".rstrip(),
+ }
+ except httpx.RequestError as exc:
+ logger.warning("MiMo web search request error: %s", exc)
+ return {"success": False, "error": f"Could not reach MiMo API: {exc}"}
+
+ try:
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001
+ logger.warning("MiMo web search bad JSON: %s", exc)
+ return {
+ "success": False,
+ "error": "Could not parse MiMo API response as JSON",
+ }
+
+ # Check for API-level error envelope.
+ api_error = data.get("error") if isinstance(data, dict) else None
+ if isinstance(api_error, dict):
+ err_msg = api_error.get("message") or api_error.get("code") or "unknown error"
+ logger.warning("MiMo web search returned error envelope: %s", err_msg)
+ return {"success": False, "error": f"MiMo returned an error: {err_msg}"}
+
+ web_results = self._extract_results(data, limit=limit)
+ if not web_results:
+ return {"success": True, "data": {"web": []}}
+
+ return {"success": True, "data": {"web": web_results}}
+
+ # -- Prompt + parsing -------------------------------------------------
+
+ @staticmethod
+ def _build_prompt(query: str, limit: int) -> str:
+ """Compose the prompt that asks MiMo to act as a search engine."""
+ return (
+ "Use the web_search tool to find current information for the query below, "
+ "then respond with ONLY a single JSON object — no prose, no markdown "
+ "fences — matching this exact schema:\n\n"
+ '{"results": [{"title": "string", "url": "string", '
+ '"description": "1-2 sentence summary"}]}\n\n'
+ f"Return at most {limit} results, ordered by relevance, with absolute "
+ "https:// URLs. If no usable results exist, return "
+ '{"results": []}.\n\n'
+ f"Query: {query}"
+ )
+
+ @classmethod
+ def _extract_results(
+ cls,
+ response_data: Dict[str, Any],
+ *,
+ limit: int,
+ ) -> List[Dict[str, Any]]:
+ """Pull a ``[{title, url, description, position}, ...]`` list out of
+ a Chat Completions API reply.
+
+ Strategy:
+ 1. Walk ``choices[*].message.content`` for JSON blocks and try to
+ parse the first object that has a ``results`` list.
+ 2. If the JSON path fails, fall back to ``choices[*].message.annotations``
+ (``url_citation`` entries).
+ """
+ choices = response_data.get("choices")
+ if not isinstance(choices, list) or not choices:
+ return []
+
+ message = choices[0].get("message") if isinstance(choices[0], dict) else {}
+ if not isinstance(message, dict):
+ return {}
+
+ content = message.get("content") or ""
+ annotations = message.get("annotations") or []
+
+ # Primary path: parse JSON from model output.
+ if isinstance(content, str) and content.strip():
+ parsed = cls._try_parse_json_results(content, limit=limit)
+ if parsed:
+ return parsed
+
+ # Secondary path: derive results from annotations (url_citation).
+ if isinstance(annotations, list):
+ annotation_results = cls._results_from_annotations(annotations, limit=limit)
+ if annotation_results:
+ return annotation_results
+
+ return []
+
+ @staticmethod
+ def _try_parse_json_results(
+ text: str,
+ *,
+ limit: int,
+ ) -> Optional[List[Dict[str, Any]]]:
+ """Parse a JSON object with a ``results`` array out of ``text``."""
+ candidates = [text]
+ match = _JSON_BLOCK_RE.search(text)
+ if match and match.group(0) != text:
+ candidates.append(match.group(0))
+
+ for candidate in candidates:
+ try:
+ parsed = json.loads(candidate)
+ except (json.JSONDecodeError, ValueError):
+ continue
+ if not isinstance(parsed, dict):
+ continue
+ results = parsed.get("results")
+ if not isinstance(results, list):
+ continue
+ normalized: List[Dict[str, Any]] = []
+ for row in results[:limit]:
+ if not isinstance(row, dict):
+ continue
+ url = str(row.get("url", "")).strip()
+ if not url:
+ continue
+ normalized.append(
+ {
+ "title": str(row.get("title", "")).strip(),
+ "url": url,
+ "description": str(row.get("description", "")).strip(),
+ "position": len(normalized) + 1,
+ }
+ )
+ if normalized:
+ return normalized
+ return None
+
+ @staticmethod
+ def _results_from_annotations(
+ annotations: List[Dict[str, Any]],
+ *,
+ limit: int,
+ ) -> List[Dict[str, Any]]:
+ """Extract search results from MiMo's url_citation annotations."""
+ results: List[Dict[str, Any]] = []
+ for ann in annotations:
+ if not isinstance(ann, dict):
+ continue
+ if ann.get("type") != "url_citation":
+ continue
+ url = str(ann.get("url", "")).strip()
+ if not url:
+ continue
+ results.append(
+ {
+ "title": str(ann.get("title", "")).strip(),
+ "url": url,
+ "description": str(ann.get("summary", "")).strip(),
+ "position": len(results) + 1,
+ }
+ )
+ if len(results) >= limit:
+ break
+ return results
+
+ def get_setup_schema(self) -> Dict[str, Any]:
+ return {
+ "name": "Xiaomi MiMo Web Search",
+ "badge": "paid",
+ "tag": "MiMo 联网搜索 — server-side search, great for Chinese content.",
+ "env_vars": [
+ {
+ "key": "XIAOMI_API_KEY",
+ "prompt": "Xiaomi MiMo API key",
+ "url": "https://platform.xiaomimimo.com",
+ },
+ ],
+ }
diff --git a/test_search.py b/test_search.py
new file mode 100644
index 0000000..c103bdd
--- /dev/null
+++ b/test_search.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+"""Standalone test script for MiMo Web Search.
+
+Usage:
+ export XIAOMI_API_KEY="your-key-here"
+ # export XIAOMI_BASE_URL="https://token-plan-cn.xiaomimimo.com/v1" # if using Token Plan
+ python3 test_search.py "小米17解锁"
+ python3 test_search.py "YU7 GT 价格" --limit 10
+ python3 test_search.py "今天天气" --raw # dump full API response
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+
+# ---------------------------------------------------------------------------
+# Env loader
+# ---------------------------------------------------------------------------
+
+def _load_env() -> None:
+ """Load ~/.hermes/.env if present (no-op when env vars are already set)."""
+ env_path = os.path.expanduser("~/.hermes/.env")
+ if not os.path.exists(env_path):
+ return
+ with open(env_path) as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ k, v = line.split("=", 1)
+ k, v = k.strip(), v.strip()
+ if k and k not in os.environ:
+ os.environ[k] = v
+
+# ---------------------------------------------------------------------------
+# Core search (self-contained, no Hermes imports needed)
+# ---------------------------------------------------------------------------
+
+import httpx, re
+
+_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1"
+_DEFAULT_MODEL = "mimo-v2.5"
+_JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}", re.MULTILINE)
+
+
+def mimo_search(
+ query: str,
+ *,
+ limit: int = 5,
+ model: str | None = None,
+ max_keyword: int = 3,
+ raw: bool = False,
+) -> dict:
+ """Call MiMo's Chat Completions API with web_search tool enabled."""
+ api_key = os.environ.get("XIAOMI_API_KEY", "").strip()
+ if not api_key:
+ return {"success": False, "error": "XIAOMI_API_KEY not set"}
+
+ base_url = os.environ.get("XIAOMI_BASE_URL", "").strip().rstrip("/") or _DEFAULT_BASE_URL
+ model = model or _DEFAULT_MODEL
+
+ prompt = (
+ "Use the web_search tool to find current information for the query below, "
+ "then respond with ONLY a single JSON object — no prose, no markdown "
+ "fences — matching this exact schema:\n\n"
+ '{"results": [{"title": "string", "url": "string", '
+ '"description": "1-2 sentence summary"}]}\n\n'
+ f"Return at most {limit} results, ordered by relevance, with absolute "
+ 'https:// URLs. If no usable results exist, return '
+ '{"results": []}.\n\n'
+ f"Query: {query}"
+ )
+
+ payload = {
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ "tools": [
+ {
+ "type": "web_search",
+ "force_search": True,
+ "max_keyword": max_keyword,
+ "limit": min(limit, 5),
+ }
+ ],
+ "max_completion_tokens": 2048,
+ "temperature": 1.0,
+ "top_p": 0.95,
+ "stream": False,
+ }
+
+ resp = httpx.post(
+ f"{base_url}/chat/completions",
+ headers={"api-key": api_key, "Content-Type": "application/json"},
+ json=payload,
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ if raw:
+ return data
+
+ # Parse results from annotations
+ choices = data.get("choices", [])
+ if not choices:
+ return {"success": True, "data": {"web": []}}
+
+ message = choices[0].get("message", {})
+ annotations = message.get("annotations", [])
+
+ seen_urls: set[str] = set()
+ results = []
+ for ann in annotations:
+ if ann.get("type") != "url_citation":
+ continue
+ url = str(ann.get("url", "")).strip()
+ if not url or url in seen_urls:
+ continue
+ seen_urls.add(url)
+ results.append({
+ "title": str(ann.get("title", "")).strip(),
+ "url": url,
+ "description": str(ann.get("summary", "")).strip(),
+ "site_name": str(ann.get("site_name", "")).strip(),
+ "publish_time": str(ann.get("publish_time", "")).strip(),
+ "position": len(results) + 1,
+ })
+ if len(results) >= limit:
+ break
+
+ usage = data.get("usage", {})
+ wsu = usage.get("web_search_usage", {})
+
+ return {
+ "success": True,
+ "data": {"web": results},
+ "answer": message.get("content", ""),
+ "usage": {
+ "prompt_tokens": usage.get("prompt_tokens"),
+ "completion_tokens": usage.get("completion_tokens"),
+ "total_tokens": usage.get("total_tokens"),
+ "search_keywords": wsu.get("tool_usage"),
+ "pages_fetched": wsu.get("page_usage"),
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="MiMo Web Search CLI")
+ parser.add_argument("query", help="Search query")
+ parser.add_argument("--limit", type=int, default=5, help="Max results (default: 5)")
+ parser.add_argument("--model", default=None, help="MiMo model (default: mimo-v2.5)")
+ parser.add_argument("--max-keyword", type=int, default=3, help="Max keywords per round")
+ parser.add_argument("--raw", action="store_true", help="Dump raw API response")
+ args = parser.parse_args()
+
+ _load_env()
+
+ try:
+ result = mimo_search(
+ args.query,
+ limit=args.limit,
+ model=args.model,
+ max_keyword=args.max_keyword,
+ raw=args.raw,
+ )
+ except httpx.HTTPStatusError as exc:
+ print(f"HTTP {exc.response.status_code}: {exc.response.text[:500]}", file=sys.stderr)
+ sys.exit(1)
+ except httpx.RequestError as exc:
+ print(f"Connection error: {exc}", file=sys.stderr)
+ sys.exit(1)
+
+ if args.raw:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return
+
+ if not result.get("success"):
+ print(f"Error: {result.get('error')}", file=sys.stderr)
+ sys.exit(1)
+
+ answer = result.get("answer", "")
+ web = result.get("data", {}).get("web", [])
+ usage = result.get("usage", {})
+
+ if answer:
+ print(f"\n{'=' * 60}")
+ print("ANSWER")
+ print('=' * 60)
+ print(answer)
+
+ print(f"\n{'=' * 60}")
+ print(f"SEARCH RESULTS ({len(web)} items)")
+ print('=' * 60)
+ for r in web:
+ site = f" [{r.get('site_name', '')}]" if r.get("site_name") else ""
+ print(f"\n [{r['position']}]{site} {r['title']}")
+ print(f" {r['url']}")
+ desc = r.get("description", "")
+ if desc:
+ print(f" {desc[:150]}{'...' if len(desc) > 150 else ''}")
+
+ print(f"\n{'=' * 60}")
+ print("USAGE")
+ print('=' * 60)
+ print(f" prompt tokens: {usage.get('prompt_tokens', '?')}")
+ print(f" completion tokens: {usage.get('completion_tokens', '?')}")
+ print(f" total tokens: {usage.get('total_tokens', '?')}")
+ print(f" search keywords: {usage.get('search_keywords', '?')}")
+ print(f" pages fetched: {usage.get('pages_fetched', '?')}")
+ print()
+
+
+if __name__ == "__main__":
+ main()