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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
@@ -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,3 @@
1
+ from crewai_cloro.tools import CloroAIAnswerTool, CloroError, CloroGoogleSearchTool
2
+
3
+ __all__ = ["CloroAIAnswerTool", "CloroError", "CloroGoogleSearchTool"]
@@ -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")