crewai-cloro 0.1.0__tar.gz
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.
- crewai_cloro-0.1.0/.gitignore +7 -0
- crewai_cloro-0.1.0/LICENSE +21 -0
- crewai_cloro-0.1.0/PKG-INFO +112 -0
- crewai_cloro-0.1.0/README.md +92 -0
- crewai_cloro-0.1.0/pyproject.toml +39 -0
- crewai_cloro-0.1.0/src/crewai_cloro/__init__.py +3 -0
- crewai_cloro-0.1.0/src/crewai_cloro/tools.py +196 -0
- crewai_cloro-0.1.0/tests/test_tools.py +141 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cloro
|
|
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,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: crewai-cloro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CrewAI tools for cloro: live Google search results with the AI Overview, and Google AI Mode, Bing AI Mode, ChatGPT and Gemini answers with sources.
|
|
5
|
+
Project-URL: Homepage, https://cloro.dev/?utm_source=crewai-cloro&utm_medium=byok
|
|
6
|
+
Project-URL: Documentation, https://github.com/cloro-dev/crewai-cloro#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/cloro-dev/crewai-cloro/issues
|
|
8
|
+
Project-URL: Source, https://github.com/cloro-dev/crewai-cloro
|
|
9
|
+
Author-email: cloro <support@cloro.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: AI Mode,AI Overview,Bing,ChatGPT,CrewAI,Gemini,Google,Web Search,agents,cloro
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Requires-Python: <3.14,>=3.10
|
|
17
|
+
Requires-Dist: crewai>=1.0.0
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# crewai-cloro
|
|
22
|
+
|
|
23
|
+
[CrewAI](https://www.crewai.com) tools for [cloro](https://cloro.dev/?utm_source=crewai-cloro&utm_medium=byok). cloro
|
|
24
|
+
loads the live page of a search engine or an AI assistant for a query and a country, and returns the result as
|
|
25
|
+
structured data.
|
|
26
|
+
|
|
27
|
+
| Tool | cloro endpoint | The agent gets | Credits per call |
|
|
28
|
+
|---|---|---|--:|
|
|
29
|
+
| `CloroGoogleSearchTool` | `POST /v1/monitor/google` | Google organic results: title, URL and snippet. With `include_ai_overview=True`, the Google AI Overview and its sources first. | 5, or 7 with the AI Overview |
|
|
30
|
+
| `CloroAIAnswerTool(engine="google_ai_mode")` | `POST /v1/monitor/aimode` | The Google AI Mode answer as Markdown, with its sources | 6 |
|
|
31
|
+
| `CloroAIAnswerTool(engine="bing_ai_mode")` | `POST /v1/monitor/bing/aimode` | The Bing AI Mode (Copilot Search in Bing) answer, with its sources | 6 |
|
|
32
|
+
| `CloroAIAnswerTool(engine="chatgpt")` | `POST /v1/monitor/chatgpt` | The ChatGPT answer that users see, with its sources | 7 |
|
|
33
|
+
| `CloroAIAnswerTool(engine="gemini")` | `POST /v1/monitor/gemini` | The Gemini answer, with its sources | 6 |
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install crewai-cloro
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Get an API key at [dashboard.cloro.dev](https://dashboard.cloro.dev/api-keys?utm_source=crewai-cloro&utm_medium=byok).
|
|
42
|
+
The free plan includes 500 credits each month. Set the key as `CLORO_API_KEY`, or pass `api_key=...` to a tool.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from crewai import Agent, Crew, Task
|
|
48
|
+
from crewai_cloro import CloroAIAnswerTool, CloroGoogleSearchTool
|
|
49
|
+
|
|
50
|
+
researcher = Agent(
|
|
51
|
+
role="Search analyst",
|
|
52
|
+
goal="Compare what Google and AI assistants say about a topic.",
|
|
53
|
+
backstory="You check live sources and cite the URLs you use.",
|
|
54
|
+
tools=[
|
|
55
|
+
CloroGoogleSearchTool(max_results=5, include_ai_overview=True),
|
|
56
|
+
CloroAIAnswerTool(engine="chatgpt"),
|
|
57
|
+
CloroAIAnswerTool(engine="google_ai_mode"),
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
task = Task(
|
|
62
|
+
description="Which SERP APIs do Google, ChatGPT and Google AI Mode recommend for AI agents?",
|
|
63
|
+
expected_output="A short comparison with the cited URLs.",
|
|
64
|
+
agent=researcher,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
print(Crew(agents=[researcher], tasks=[task]).kickoff())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Each `CloroAIAnswerTool` gets its own tool name, `cloro_<engine>`, so one agent can hold several engines. Pass
|
|
71
|
+
`name=` to change it.
|
|
72
|
+
|
|
73
|
+
A direct call returns the same text the agent gets:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
print(CloroGoogleSearchTool().run(query="retrieval-augmented generation", country="GB"))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Parameters
|
|
80
|
+
|
|
81
|
+
Both tools take:
|
|
82
|
+
|
|
83
|
+
- `api_key`: the cloro API key. Defaults to the `CLORO_API_KEY` environment variable.
|
|
84
|
+
- `country`: ISO 3166-1 alpha-2 country code for the search location (default `US`). The agent can also pass
|
|
85
|
+
`country` per call.
|
|
86
|
+
- `timeout`: request timeout in seconds (default 180). An AI answer can take 15 to 60 seconds.
|
|
87
|
+
|
|
88
|
+
`CloroGoogleSearchTool` also takes `max_results` (default 10) and `include_ai_overview` (default `False`).
|
|
89
|
+
Results with an empty link or a Google redirect link are skipped.
|
|
90
|
+
|
|
91
|
+
`CloroAIAnswerTool` also takes `engine`: `google_ai_mode` (default), `bing_ai_mode`, `chatgpt` or `gemini`.
|
|
92
|
+
|
|
93
|
+
## Limits
|
|
94
|
+
|
|
95
|
+
- Bing AI Mode accepts a prompt of up to 3,500 characters after URL encoding. Non-Latin scripts allow fewer
|
|
96
|
+
characters. The tool refuses a longer prompt before it sends the request.
|
|
97
|
+
- Your plan sets how many requests can run at the same time. A request sent while all slots are busy gets a `429`
|
|
98
|
+
(`CONCURRENCY_LIMIT_EXCEEDED`).
|
|
99
|
+
- An API error raises `CloroError` with the HTTP status, the cloro error code and the message.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
uv run --python 3.12 --with crewai --with httpx --with pytest -- env PYTHONPATH=src python -m pytest tests
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The tests mock the network. The API reference is at
|
|
108
|
+
[cloro.dev/docs](https://cloro.dev/docs?utm_source=crewai-cloro&utm_medium=byok).
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# crewai-cloro
|
|
2
|
+
|
|
3
|
+
[CrewAI](https://www.crewai.com) tools for [cloro](https://cloro.dev/?utm_source=crewai-cloro&utm_medium=byok). cloro
|
|
4
|
+
loads the live page of a search engine or an AI assistant for a query and a country, and returns the result as
|
|
5
|
+
structured data.
|
|
6
|
+
|
|
7
|
+
| Tool | cloro endpoint | The agent gets | Credits per call |
|
|
8
|
+
|---|---|---|--:|
|
|
9
|
+
| `CloroGoogleSearchTool` | `POST /v1/monitor/google` | Google organic results: title, URL and snippet. With `include_ai_overview=True`, the Google AI Overview and its sources first. | 5, or 7 with the AI Overview |
|
|
10
|
+
| `CloroAIAnswerTool(engine="google_ai_mode")` | `POST /v1/monitor/aimode` | The Google AI Mode answer as Markdown, with its sources | 6 |
|
|
11
|
+
| `CloroAIAnswerTool(engine="bing_ai_mode")` | `POST /v1/monitor/bing/aimode` | The Bing AI Mode (Copilot Search in Bing) answer, with its sources | 6 |
|
|
12
|
+
| `CloroAIAnswerTool(engine="chatgpt")` | `POST /v1/monitor/chatgpt` | The ChatGPT answer that users see, with its sources | 7 |
|
|
13
|
+
| `CloroAIAnswerTool(engine="gemini")` | `POST /v1/monitor/gemini` | The Gemini answer, with its sources | 6 |
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install crewai-cloro
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Get an API key at [dashboard.cloro.dev](https://dashboard.cloro.dev/api-keys?utm_source=crewai-cloro&utm_medium=byok).
|
|
22
|
+
The free plan includes 500 credits each month. Set the key as `CLORO_API_KEY`, or pass `api_key=...` to a tool.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from crewai import Agent, Crew, Task
|
|
28
|
+
from crewai_cloro import CloroAIAnswerTool, CloroGoogleSearchTool
|
|
29
|
+
|
|
30
|
+
researcher = Agent(
|
|
31
|
+
role="Search analyst",
|
|
32
|
+
goal="Compare what Google and AI assistants say about a topic.",
|
|
33
|
+
backstory="You check live sources and cite the URLs you use.",
|
|
34
|
+
tools=[
|
|
35
|
+
CloroGoogleSearchTool(max_results=5, include_ai_overview=True),
|
|
36
|
+
CloroAIAnswerTool(engine="chatgpt"),
|
|
37
|
+
CloroAIAnswerTool(engine="google_ai_mode"),
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
task = Task(
|
|
42
|
+
description="Which SERP APIs do Google, ChatGPT and Google AI Mode recommend for AI agents?",
|
|
43
|
+
expected_output="A short comparison with the cited URLs.",
|
|
44
|
+
agent=researcher,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
print(Crew(agents=[researcher], tasks=[task]).kickoff())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Each `CloroAIAnswerTool` gets its own tool name, `cloro_<engine>`, so one agent can hold several engines. Pass
|
|
51
|
+
`name=` to change it.
|
|
52
|
+
|
|
53
|
+
A direct call returns the same text the agent gets:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
print(CloroGoogleSearchTool().run(query="retrieval-augmented generation", country="GB"))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Parameters
|
|
60
|
+
|
|
61
|
+
Both tools take:
|
|
62
|
+
|
|
63
|
+
- `api_key`: the cloro API key. Defaults to the `CLORO_API_KEY` environment variable.
|
|
64
|
+
- `country`: ISO 3166-1 alpha-2 country code for the search location (default `US`). The agent can also pass
|
|
65
|
+
`country` per call.
|
|
66
|
+
- `timeout`: request timeout in seconds (default 180). An AI answer can take 15 to 60 seconds.
|
|
67
|
+
|
|
68
|
+
`CloroGoogleSearchTool` also takes `max_results` (default 10) and `include_ai_overview` (default `False`).
|
|
69
|
+
Results with an empty link or a Google redirect link are skipped.
|
|
70
|
+
|
|
71
|
+
`CloroAIAnswerTool` also takes `engine`: `google_ai_mode` (default), `bing_ai_mode`, `chatgpt` or `gemini`.
|
|
72
|
+
|
|
73
|
+
## Limits
|
|
74
|
+
|
|
75
|
+
- Bing AI Mode accepts a prompt of up to 3,500 characters after URL encoding. Non-Latin scripts allow fewer
|
|
76
|
+
characters. The tool refuses a longer prompt before it sends the request.
|
|
77
|
+
- Your plan sets how many requests can run at the same time. A request sent while all slots are busy gets a `429`
|
|
78
|
+
(`CONCURRENCY_LIMIT_EXCEEDED`).
|
|
79
|
+
- An API error raises `CloroError` with the HTTP status, the cloro error code and the message.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv run --python 3.12 --with crewai --with httpx --with pytest -- env PYTHONPATH=src python -m pytest tests
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The tests mock the network. The API reference is at
|
|
88
|
+
[cloro.dev/docs](https://cloro.dev/docs?utm_source=crewai-cloro&utm_medium=byok).
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crewai-cloro"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CrewAI tools for cloro: live Google search results with the AI Overview, and Google AI Mode, Bing AI Mode, ChatGPT and Gemini answers with sources."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<3.14"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["CrewAI", "cloro", "Web Search", "Google", "AI Overview", "AI Mode", "Bing", "ChatGPT", "Gemini", "agents"]
|
|
14
|
+
authors = [{ name = "cloro", email = "support@cloro.dev" }]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["crewai>=1.0.0", "httpx>=0.27.0"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://cloro.dev/?utm_source=crewai-cloro&utm_medium=byok"
|
|
24
|
+
Documentation = "https://github.com/cloro-dev/crewai-cloro#readme"
|
|
25
|
+
Issues = "https://github.com/cloro-dev/crewai-cloro/issues"
|
|
26
|
+
Source = "https://github.com/cloro-dev/crewai-cloro"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = ["pytest>=8.0", "ruff>=0.11"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.sdist]
|
|
32
|
+
include = ["src/", "tests/", "LICENSE", "README.md"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/crewai_cloro"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 120
|
|
39
|
+
target-version = "py310"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""CrewAI tools for cloro: live Google results and AI search engine answers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from crewai.tools import BaseTool, EnvVar
|
|
9
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
10
|
+
|
|
11
|
+
CLORO_API_BASE = "https://api.cloro.dev"
|
|
12
|
+
USER_AGENT = "crewai-cloro"
|
|
13
|
+
API_KEY_ENV = EnvVar(
|
|
14
|
+
name="CLORO_API_KEY", description="API key for cloro (https://dashboard.cloro.dev).", required=True
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Bing AI Mode refuses a prompt longer than this after URL encoding.
|
|
18
|
+
BING_PROMPT_LIMIT = 3500
|
|
19
|
+
|
|
20
|
+
Engine = Literal["google_ai_mode", "bing_ai_mode", "chatgpt", "gemini"]
|
|
21
|
+
|
|
22
|
+
_ENGINES: dict[str, tuple[str, str]] = {
|
|
23
|
+
"google_ai_mode": ("/v1/monitor/aimode", "Google AI Mode"),
|
|
24
|
+
"bing_ai_mode": ("/v1/monitor/bing/aimode", "Bing AI Mode (Copilot Search in Bing)"),
|
|
25
|
+
"chatgpt": ("/v1/monitor/chatgpt", "ChatGPT"),
|
|
26
|
+
"gemini": ("/v1/monitor/gemini", "Gemini"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CloroError(RuntimeError):
|
|
31
|
+
"""The cloro API returned an error or a response that cannot be read."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_direct_link(url: Any) -> bool:
|
|
35
|
+
"""Return True for an http(s) link that is not a Google redirect or search page."""
|
|
36
|
+
return (
|
|
37
|
+
isinstance(url, str)
|
|
38
|
+
and url.startswith(("http://", "https://"))
|
|
39
|
+
and "google.com/goto" not in url
|
|
40
|
+
and "google.com/search" not in url
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _encoded_length(prompt: str) -> int:
|
|
45
|
+
return len(quote(prompt.strip(), safe="-_.!~*'()").replace("%20", "+"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sources(items: Any) -> list[tuple[str, str]]:
|
|
49
|
+
sources: list[tuple[str, str]] = []
|
|
50
|
+
for item in items or []:
|
|
51
|
+
url = item.get("url") if isinstance(item, dict) else None
|
|
52
|
+
if _is_direct_link(url) and url not in [u for _, u in sources]:
|
|
53
|
+
sources.append((item.get("label") or "", url))
|
|
54
|
+
return sources
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _format_sources(sources: list[tuple[str, str]]) -> str:
|
|
58
|
+
return "\n".join(f"- {title}: {url}" if title else f"- {url}" for title, url in sources)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _CloroTool(BaseTool):
|
|
62
|
+
api_key: str | None = Field(default=None, description="cloro API key. Defaults to CLORO_API_KEY.")
|
|
63
|
+
country: str = Field(default="US", description="ISO 3166-1 alpha-2 country code for the search location.")
|
|
64
|
+
timeout: float = Field(default=180.0, description="Request timeout in seconds.")
|
|
65
|
+
env_vars: list[EnvVar] = [API_KEY_ENV]
|
|
66
|
+
_key: str = PrivateAttr(default="")
|
|
67
|
+
|
|
68
|
+
def model_post_init(self, context: Any) -> None:
|
|
69
|
+
super().model_post_init(context)
|
|
70
|
+
key = self.api_key or os.environ.get("CLORO_API_KEY")
|
|
71
|
+
if not key:
|
|
72
|
+
raise ValueError("Set CLORO_API_KEY or pass api_key. Get a key at https://dashboard.cloro.dev")
|
|
73
|
+
self._key = key
|
|
74
|
+
|
|
75
|
+
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
try:
|
|
77
|
+
response = httpx.post(
|
|
78
|
+
f"{CLORO_API_BASE}{path}",
|
|
79
|
+
json=payload,
|
|
80
|
+
headers={
|
|
81
|
+
"Authorization": f"Bearer {self._key}",
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"User-Agent": USER_AGENT,
|
|
84
|
+
},
|
|
85
|
+
timeout=self.timeout,
|
|
86
|
+
)
|
|
87
|
+
except httpx.HTTPError as error:
|
|
88
|
+
raise CloroError(f"Request to cloro {path} failed: {error}") from error
|
|
89
|
+
try:
|
|
90
|
+
data = response.json()
|
|
91
|
+
except ValueError:
|
|
92
|
+
data = None
|
|
93
|
+
if response.is_error or not isinstance(data, dict) or not data.get("success"):
|
|
94
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
95
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
96
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
97
|
+
raise CloroError(
|
|
98
|
+
f"cloro {path} returned HTTP {response.status_code}: {code or 'no error code'} {message or ''}".strip()
|
|
99
|
+
)
|
|
100
|
+
result = data.get("result")
|
|
101
|
+
if not isinstance(result, dict):
|
|
102
|
+
raise CloroError(f"cloro {path} returned no result.")
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CloroGoogleSearchInput(BaseModel):
|
|
107
|
+
"""Input schema for CloroGoogleSearchTool."""
|
|
108
|
+
|
|
109
|
+
query: str = Field(..., description="The search query.")
|
|
110
|
+
country: str | None = Field(default=None, description="Optional ISO 3166-1 alpha-2 country code, for example GB.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CloroGoogleSearchTool(_CloroTool):
|
|
114
|
+
"""Live Google organic results, and optionally the Google AI Overview, through cloro."""
|
|
115
|
+
|
|
116
|
+
name: str = "cloro_google_search"
|
|
117
|
+
description: str = (
|
|
118
|
+
"Search Google live and return the organic results of the first results page: title, URL and snippet. "
|
|
119
|
+
"Use it for current facts, sources and what ranks on Google for a query."
|
|
120
|
+
)
|
|
121
|
+
args_schema: type[BaseModel] = CloroGoogleSearchInput
|
|
122
|
+
max_results: int = Field(default=10, description="The maximum number of organic results to return.")
|
|
123
|
+
include_ai_overview: bool = Field(
|
|
124
|
+
default=False, description="Also return the Google AI Overview and its sources. Costs 2 more credits."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _run(self, query: str, country: str | None = None) -> str:
|
|
128
|
+
payload: dict[str, Any] = {"query": query, "country": (country or self.country).upper()}
|
|
129
|
+
if self.include_ai_overview:
|
|
130
|
+
payload["include"] = {"aioverview": True}
|
|
131
|
+
result = self._post("/v1/monitor/google", payload)
|
|
132
|
+
|
|
133
|
+
parts: list[str] = []
|
|
134
|
+
overview = result.get("aioverview")
|
|
135
|
+
if self.include_ai_overview and isinstance(overview, dict) and overview.get("text"):
|
|
136
|
+
text = f"Google AI Overview:\n{overview['text']}"
|
|
137
|
+
sources = _sources(overview.get("sources"))
|
|
138
|
+
if sources:
|
|
139
|
+
text += "\nSources:\n" + _format_sources(sources)
|
|
140
|
+
parts.append(text)
|
|
141
|
+
|
|
142
|
+
organic = [
|
|
143
|
+
item
|
|
144
|
+
for item in result.get("organicResults") or []
|
|
145
|
+
if isinstance(item, dict) and _is_direct_link(item.get("link"))
|
|
146
|
+
][: self.max_results]
|
|
147
|
+
for index, item in enumerate(organic, start=1):
|
|
148
|
+
parts.append(f"{index}. {item.get('title') or ''}\n{item['link']}\n{item.get('snippet') or ''}".strip())
|
|
149
|
+
return "\n\n".join(parts) if parts else f"No Google results for: {query}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CloroAIAnswerInput(BaseModel):
|
|
153
|
+
"""Input schema for CloroAIAnswerTool."""
|
|
154
|
+
|
|
155
|
+
prompt: str = Field(..., description="The question to ask the AI search engine.")
|
|
156
|
+
country: str | None = Field(default=None, description="Optional ISO 3166-1 alpha-2 country code, for example GB.")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CloroAIAnswerTool(_CloroTool):
|
|
160
|
+
"""The answer an AI search engine gives to a prompt, with the sources it cites, through cloro."""
|
|
161
|
+
|
|
162
|
+
name: str = ""
|
|
163
|
+
description: str = ""
|
|
164
|
+
args_schema: type[BaseModel] = CloroAIAnswerInput
|
|
165
|
+
engine: Engine = Field(
|
|
166
|
+
default="google_ai_mode",
|
|
167
|
+
description="The AI search engine: google_ai_mode, bing_ai_mode, chatgpt or gemini.",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def model_post_init(self, context: Any) -> None:
|
|
171
|
+
label = _ENGINES[self.engine][1]
|
|
172
|
+
if not self.name:
|
|
173
|
+
self.name = f"cloro_{self.engine}"
|
|
174
|
+
if not self.description:
|
|
175
|
+
self.description = (
|
|
176
|
+
f"Ask {label} a question live and return the answer it shows users, as Markdown, "
|
|
177
|
+
"with the sources it cites. Use it to see what this AI engine says about a topic or a brand."
|
|
178
|
+
)
|
|
179
|
+
super().model_post_init(context)
|
|
180
|
+
|
|
181
|
+
def _run(self, prompt: str, country: str | None = None) -> str:
|
|
182
|
+
path, label = _ENGINES[self.engine]
|
|
183
|
+
if self.engine == "bing_ai_mode" and _encoded_length(prompt) > BING_PROMPT_LIMIT:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"The prompt is too long for Bing AI Mode: at most 3,500 characters after URL encoding. "
|
|
186
|
+
"Non-Latin scripts allow fewer characters."
|
|
187
|
+
)
|
|
188
|
+
result = self._post(
|
|
189
|
+
path, {"prompt": prompt, "country": (country or self.country).upper(), "include": {"markdown": True}}
|
|
190
|
+
)
|
|
191
|
+
answer = result.get("markdown") or result.get("text") or ""
|
|
192
|
+
text = f"{label} answer:\n{answer}" if answer else f"{label} returned no answer for: {prompt}"
|
|
193
|
+
sources = _sources(result.get("sources"))
|
|
194
|
+
if sources:
|
|
195
|
+
text += "\n\nSources:\n" + _format_sources(sources)
|
|
196
|
+
return text
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
from crewai.tools import BaseTool
|
|
6
|
+
|
|
7
|
+
from crewai_cloro import CloroAIAnswerTool, CloroError, CloroGoogleSearchTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _response(status: int, body: dict) -> httpx.Response:
|
|
11
|
+
return httpx.Response(status, json=body, request=httpx.Request("POST", "https://api.cloro.dev"))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
GOOGLE = {
|
|
15
|
+
"success": True,
|
|
16
|
+
"result": {
|
|
17
|
+
"organicResults": [
|
|
18
|
+
{"position": 1, "title": "First", "link": "https://example.com/a", "snippet": "A."},
|
|
19
|
+
{"position": 2, "title": "Unresolved", "link": "", "snippet": "No link."},
|
|
20
|
+
{"position": 3, "title": "Redirect", "link": "https://www.google.com/goto?x=1", "snippet": "R."},
|
|
21
|
+
{"position": 4, "title": "Second", "link": "https://example.org/b", "snippet": "B."},
|
|
22
|
+
],
|
|
23
|
+
"aioverview": {
|
|
24
|
+
"text": "An overview.",
|
|
25
|
+
"sources": [{"label": "Src", "url": "https://example.net"}],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ANSWER = {
|
|
31
|
+
"success": True,
|
|
32
|
+
"result": {
|
|
33
|
+
"text": "Plain.",
|
|
34
|
+
"markdown": "**Answer.**",
|
|
35
|
+
"sources": [
|
|
36
|
+
{"position": 1, "label": "Source", "url": "https://example.com"},
|
|
37
|
+
{"position": 2, "label": "Again", "url": "https://example.com"},
|
|
38
|
+
{"position": 3, "label": "Search page", "url": "https://www.google.com/search?q=x"},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture(autouse=True)
|
|
45
|
+
def api_key(monkeypatch):
|
|
46
|
+
monkeypatch.setenv("CLORO_API_KEY", "env-key")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tools_are_crewai_tools():
|
|
50
|
+
assert isinstance(CloroGoogleSearchTool(), BaseTool)
|
|
51
|
+
assert isinstance(CloroAIAnswerTool(), BaseTool)
|
|
52
|
+
assert [e.name for e in CloroGoogleSearchTool().env_vars] == ["CLORO_API_KEY"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_missing_key(monkeypatch):
|
|
56
|
+
monkeypatch.delenv("CLORO_API_KEY")
|
|
57
|
+
with pytest.raises(ValueError, match="CLORO_API_KEY"):
|
|
58
|
+
CloroGoogleSearchTool()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_explicit_key_wins():
|
|
62
|
+
with patch("crewai_cloro.tools.httpx.post", return_value=_response(200, GOOGLE)) as post:
|
|
63
|
+
CloroGoogleSearchTool(api_key="k").run(query="q")
|
|
64
|
+
assert post.call_args.kwargs["headers"]["Authorization"] == "Bearer k"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@patch("crewai_cloro.tools.httpx.post")
|
|
68
|
+
def test_google_search(post):
|
|
69
|
+
post.return_value = _response(200, GOOGLE)
|
|
70
|
+
out = CloroGoogleSearchTool(max_results=5).run(query="serp api", country="de")
|
|
71
|
+
|
|
72
|
+
assert post.call_args.args[0] == "https://api.cloro.dev/v1/monitor/google"
|
|
73
|
+
assert post.call_args.kwargs["json"] == {"query": "serp api", "country": "DE"}
|
|
74
|
+
assert out == "1. First\nhttps://example.com/a\nA.\n\n2. Second\nhttps://example.org/b\nB."
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@patch("crewai_cloro.tools.httpx.post")
|
|
78
|
+
def test_google_search_with_ai_overview(post):
|
|
79
|
+
post.return_value = _response(200, GOOGLE)
|
|
80
|
+
out = CloroGoogleSearchTool(include_ai_overview=True, max_results=1).run(query="q")
|
|
81
|
+
|
|
82
|
+
assert post.call_args.kwargs["json"] == {"query": "q", "country": "US", "include": {"aioverview": True}}
|
|
83
|
+
assert out.startswith("Google AI Overview:\nAn overview.\nSources:\n- Src: https://example.net\n\n1. First")
|
|
84
|
+
assert "Second" not in out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@patch("crewai_cloro.tools.httpx.post")
|
|
88
|
+
def test_google_search_no_results(post):
|
|
89
|
+
post.return_value = _response(200, {"success": True, "result": {"organicResults": []}})
|
|
90
|
+
assert CloroGoogleSearchTool().run(query="nothing") == "No Google results for: nothing"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize(
|
|
94
|
+
("engine", "path", "label"),
|
|
95
|
+
[
|
|
96
|
+
("google_ai_mode", "/v1/monitor/aimode", "Google AI Mode"),
|
|
97
|
+
("bing_ai_mode", "/v1/monitor/bing/aimode", "Bing AI Mode (Copilot Search in Bing)"),
|
|
98
|
+
("chatgpt", "/v1/monitor/chatgpt", "ChatGPT"),
|
|
99
|
+
("gemini", "/v1/monitor/gemini", "Gemini"),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
@patch("crewai_cloro.tools.httpx.post")
|
|
103
|
+
def test_ai_answers(post, engine, path, label):
|
|
104
|
+
post.return_value = _response(200, ANSWER)
|
|
105
|
+
tool = CloroAIAnswerTool(engine=engine, country="gb")
|
|
106
|
+
out = tool.run(prompt="What is a SERP?")
|
|
107
|
+
|
|
108
|
+
assert tool.name == f"cloro_{engine}"
|
|
109
|
+
assert label in tool.description
|
|
110
|
+
assert post.call_args.args[0] == f"https://api.cloro.dev{path}"
|
|
111
|
+
expected = {"prompt": "What is a SERP?", "country": "GB", "include": {"markdown": True}}
|
|
112
|
+
assert post.call_args.kwargs["json"] == expected
|
|
113
|
+
assert out == f"{label} answer:\n**Answer.**\n\nSources:\n- Source: https://example.com"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_custom_name_is_kept():
|
|
117
|
+
assert CloroAIAnswerTool(engine="gemini", name="ask_gemini").name == "ask_gemini"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@patch("crewai_cloro.tools.httpx.post")
|
|
121
|
+
def test_bing_prompt_too_long(post):
|
|
122
|
+
# 600 CJK characters are 5,400 characters once URL-encoded.
|
|
123
|
+
with pytest.raises(ValueError, match="too long for Bing AI Mode"):
|
|
124
|
+
CloroAIAnswerTool(engine="bing_ai_mode")._run(prompt="中" * 600)
|
|
125
|
+
post.assert_not_called()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@patch("crewai_cloro.tools.httpx.post")
|
|
129
|
+
def test_api_error(post):
|
|
130
|
+
post.return_value = _response(
|
|
131
|
+
402, {"success": False, "error": {"code": "INSUFFICIENT_CREDITS", "message": "Not enough credits"}}
|
|
132
|
+
)
|
|
133
|
+
with pytest.raises(CloroError, match="402: INSUFFICIENT_CREDITS Not enough credits"):
|
|
134
|
+
CloroAIAnswerTool(engine="gemini")._run(prompt="hi")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@patch("crewai_cloro.tools.httpx.post")
|
|
138
|
+
def test_network_error(post):
|
|
139
|
+
post.side_effect = httpx.ConnectError("down")
|
|
140
|
+
with pytest.raises(CloroError, match="failed"):
|
|
141
|
+
CloroGoogleSearchTool()._run(query="hi")
|